Skip to content

Commit 8d8a838

Browse files
hallelx2claude
andcommitted
feat(storage): implement S3-compatible driver via aws-sdk-go-v2
Replaces the Phase-0 stubs that returned "not yet implemented" for every method. Design: - One implementation covers AWS S3, Cloudflare R2, MinIO, Backblaze B2, DigitalOcean Spaces. Differences are three knobs: Endpoint, Region, UsePathStyle. - Credentials: static (AccessKey/SecretKey) when configured, otherwise the default AWS credential chain — env, shared config, IAM role / IRSA / instance profile. Covers the usual AWS deploy modes without code changes. - ContentType + Custom metadata round-trip through Put → Get. - Delete probes with HeadObject first so a missing key reports ErrNotFound; native S3 DeleteObject is silently idempotent and we need a signal for the caller. - isNotFound collapses NoSuchKey (GET) and NotFound (HEAD) and matches on error codes rather than concrete SDK types, so the check survives SDK minor-version churn. - PresignClient is built once at construction; SignedURL is a single call, defaulting to a 15-minute expiry when caller passes zero. Tests: - Unit tests for NewS3 validation and isNotFound matrix — run in the default go test, no network. - Integration test gated on TEST_S3_ENDPOINT / TEST_S3_BUCKET (plus credentials). Full Put → Exists → Get → SignedURL → Delete loop. Meant to be run against a local MinIO or a real bucket; will also run in CI once the workflow spins MinIO as a service. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4afa968 commit 8d8a838

4 files changed

Lines changed: 373 additions & 19 deletions

File tree

go.mod

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,24 @@ require (
2929
cloud.google.com/go/iam v1.2.2 // indirect
3030
cloud.google.com/go/longrunning v0.6.2 // indirect
3131
cloud.google.com/go/vertexai v0.12.0 // indirect
32+
github.com/aws/aws-sdk-go-v2 v1.41.6 // indirect
33+
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect
34+
github.com/aws/aws-sdk-go-v2/config v1.32.16 // indirect
35+
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect
36+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
37+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
38+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
39+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
40+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
41+
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 // indirect
42+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
43+
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 // indirect
44+
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 // indirect
45+
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
46+
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
47+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
48+
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
49+
github.com/aws/smithy-go v1.25.0 // indirect
3250
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
3351
github.com/dlclark/regexp2 v1.10.0 // indirect
3452
github.com/felixge/httpsnoop v1.0.4 // indirect

go.sum

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,42 @@ cloud.google.com/go/longrunning v0.6.2 h1:xjDfh1pQcWPEvnfjZmwjKQEcHnpz6lHjfy7Fo0
1616
cloud.google.com/go/longrunning v0.6.2/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI=
1717
cloud.google.com/go/vertexai v0.12.0 h1:zTadEo/CtsoyRXNx3uGCncoWAP1H2HakGqwznt+iMo8=
1818
cloud.google.com/go/vertexai v0.12.0/go.mod h1:8u+d0TsvBfAAd2x5R6GMgbYhsLgo3J7lmP4bR8g2ig8=
19+
github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
20+
github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
21+
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A=
22+
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg=
23+
github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM=
24+
github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc=
25+
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4=
26+
github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8=
27+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ=
28+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg=
29+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ=
30+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0=
31+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU=
32+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI=
33+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4=
34+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U=
35+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek=
36+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs=
37+
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 h1:xnvDEnw+pnj5mctWiYuFbigrEzSm35x7k4KS/ZkCANg=
38+
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14/go.mod h1:yS5rNogD8e0Wu9+l3MUwr6eENBzEeGejvINpN5PAYfY=
39+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s=
40+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo=
41+
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 h1:SE+aQ4DEqG53RRCAIHlCf//B2ycxGH7jFkpnAh/kKPM=
42+
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22/go.mod h1:ES3ynECd7fYeJIL6+oax+uIEljmfps0S70BaQzbMd/o=
43+
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 h1:kU/eBN5+MWNo/LcbNa4hWDdN76hdcd7hocU5kvu7IsU=
44+
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1/go.mod h1:Fw9aqhJicIVee1VytBBjH+l+5ov6/PhbtIK/u3rt/ls=
45+
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA=
46+
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI=
47+
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE=
48+
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw=
49+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak=
50+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg=
51+
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds=
52+
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo=
53+
github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
54+
github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
1955
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2056
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
2157
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

pkg/storage/s3.go

Lines changed: 165 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@ package storage
33
import (
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.
2836
type 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.
3344
func 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.
4790
func (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.
51110
func (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.
55140
func (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.
59159
func (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.
63175
func (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

Comments
 (0)