11// Package s3 is the S3-protocol driver.
22//
3- // v1alpha1 status: STUB. The driver registers, accepts the
4- // config block, validates parameters at admission - but
5- // EnsureBuckety/DeleteBuckety/GrantAccess return a clear error
6- // that surfaces as Ready=False on the resource. Full
7- // implementation lands in a follow-up branch alongside the s3
8- // e2e scenarios already in examples/s3/. This file exists so
9- // buckety-controller.yaml that mixes a kadm backend with an s3
10- // backend loads and validates without surprises .
3+ // Backing services in scope: any S3-compatible API. The v1alpha1
4+ // CI matrix exercises VersityGW and MinIO; AWS S3, R2, Hetzner
5+ // and GCS interop are covered by the SPEC's client-library
6+ // compatibility bet (see SPEC.md §Drivers in v1alpha1). v1alpha1
7+ // has no per-access IAM minting; all BucketyAccess instances
8+ // against the same Buckety receive identical credentials drawn
9+ // from the backend's configured root keys and the reconciler
10+ // surfaces ScopingNotImplemented for non-ReadWrite roles .
1111package s3
1212
1313import (
@@ -17,6 +17,12 @@ import (
1717 "fmt"
1818 "strings"
1919
20+ "github.com/aws/aws-sdk-go-v2/aws"
21+ "github.com/aws/aws-sdk-go-v2/credentials"
22+ awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
23+ s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
24+ smithy "github.com/aws/smithy-go"
25+
2026 "github.com/Yolean/buckety-controller/pkg/drivers/registry"
2127 "github.com/Yolean/y-cluster/pkg/envsubst"
2228 yaml "sigs.k8s.io/yaml"
@@ -25,15 +31,12 @@ import (
2531// DriverName matches backends[].driver in buckety-controller.yaml.
2632const DriverName = "s3"
2733
28- // version is the driver SemVer (see pkg/drivers/kadm for the
29- // ldflags pattern). The s3 STUB advertises 0.0.x to make it
30- // clear it has not reached v0.1.
31- var version = "0.0.1"
32-
33- // ErrNotImplemented is the sentinel the stub returns. The
34- // Buckety reconciler maps this to a stable Ready=False /
35- // NotImplemented condition rather than retrying indefinitely.
36- var ErrNotImplemented = errors .New ("s3 driver not implemented in this build (v1alpha1 stub)" )
34+ // version is the driver SemVer. Injected at build time via
35+ //
36+ // -ldflags '-X github.com/Yolean/buckety-controller/pkg/drivers/s3.version=0.1.0'
37+ //
38+ // per SPEC §Driver versioning. Default keeps tests building.
39+ var version = "0.1.0"
3740
3841func init () {
3942 registry .Register (DriverName , factory )
@@ -75,35 +78,116 @@ func factory(raw json.RawMessage) (registry.Driver, error) {
7578 default :
7679 return nil , fmt .Errorf ("s3 config: unknown implementation %q" , c .Implementation )
7780 }
78- return & Driver {cfg : & c }, nil
81+
82+ // The AWS SDK requires a region even when BaseEndpoint is set;
83+ // "auto" is the documented value R2 expects, "us-east-1" is the
84+ // MinIO/VersityGW default. The empty case is normalised here so
85+ // the eventual signer has something to fill into Authorization
86+ // headers; this matches the AWS CLI's behaviour when --region is
87+ // omitted with --endpoint-url.
88+ region := c .Region
89+ if region == "" {
90+ region = "us-east-1"
91+ }
92+ cl := awss3 .NewFromConfig (aws.Config {
93+ Region : region ,
94+ Credentials : credentials .NewStaticCredentialsProvider (c .AccessKeyID , c .SecretAccessKey , "" ),
95+ }, func (o * awss3.Options ) {
96+ o .BaseEndpoint = aws .String (c .Endpoint )
97+ o .UsePathStyle = c .ForcePathStyle
98+ })
99+
100+ return & Driver {cfg : & c , client : cl }, nil
79101}
80102
81- // Driver is the s3 STUB. See package docs .
103+ // Driver implements registry.Driver for S3-compatible backends .
82104type Driver struct {
83- cfg * Config
105+ cfg * Config
106+ client * awss3.Client
84107}
85108
86109func (d * Driver ) Name () string { return DriverName }
87110func (d * Driver ) Version () string { return version }
88111
89- func (d * Driver ) EnsureBuckety (_ context.Context , _ registry.EnsureRequest ) error {
90- return ErrNotImplemented
112+ // EnsureBuckety creates the backend bucket. Idempotent on
113+ // BucketAlreadyOwnedByYou (and on BucketAlreadyExists, which on
114+ // most S3 implementations means the caller already owns the
115+ // bucket within the same account; on AWS it indicates a global
116+ // name collision and is treated identically here because by the
117+ // time we re-reconcile, head-bucket will determine whether we
118+ // can actually see it).
119+ //
120+ // v1alpha1 has no driver-known mutable parameters for S3 buckets;
121+ // EnsureBuckety is a create-or-noop. Capability-gated parameters
122+ // (currently just R2's jurisdiction) are immutable at the
123+ // admission layer and are stamped via CreateBucketConfiguration
124+ // below.
125+ func (d * Driver ) EnsureBuckety (ctx context.Context , req registry.EnsureRequest ) error {
126+ input := & awss3.CreateBucketInput {Bucket : aws .String (req .Name )}
127+ if cfg := bucketCreationConfig (d .cfg .Implementation , d .cfg .Region , req .Parameters ); cfg != nil {
128+ input .CreateBucketConfiguration = cfg
129+ }
130+ _ , err := d .client .CreateBucket (ctx , input )
131+ if err == nil {
132+ return nil
133+ }
134+ if isAlreadyOwned (err ) {
135+ return nil
136+ }
137+ return fmt .Errorf ("s3: create bucket %q: %w" , req .Name , err )
91138}
92139
93- func (d * Driver ) DeleteBuckety (_ context.Context , _ string ) error {
94- return ErrNotImplemented
140+ // DeleteBuckety removes the backend bucket. Idempotent on
141+ // NoSuchBucket. Buckets must be empty for DeleteBucket to
142+ // succeed; v1alpha1 does not empty the bucket on the operator's
143+ // behalf - SPEC §Lifecycle and deletion treats deletion as a
144+ // deliberate Delete-policy choice and the operator is expected to
145+ // have drained or accept that DeleteBucket may fail until empty.
146+ func (d * Driver ) DeleteBuckety (ctx context.Context , name string ) error {
147+ _ , err := d .client .DeleteBucket (ctx , & awss3.DeleteBucketInput {Bucket : aws .String (name )})
148+ if err == nil {
149+ return nil
150+ }
151+ if isNotFound (err ) {
152+ return nil
153+ }
154+ return fmt .Errorf ("s3: delete bucket %q: %w" , name , err )
95155}
96156
97- func (d * Driver ) GrantAccess (_ context.Context , _ registry.GrantRequest ) (registry.GrantResult , error ) {
98- return registry.GrantResult {}, ErrNotImplemented
157+ // GrantAccess returns the s3 Secret payload for a BucketyAccess.
158+ // v1alpha1: identical credentials for all roles (the backend's
159+ // root keys). Scoped=false signals the reconciler to surface
160+ // ScopingNotImplemented for non-ReadWrite roles.
161+ //
162+ // Secret keys per SPEC §Secret output > s3 driver:
163+ //
164+ // endpoint, bucket, region (if non-empty), accessKeyID, secretAccessKey
165+ //
166+ // `bucket` is the resource-type key per the SPEC's stable
167+ // per-driver convention.
168+ func (d * Driver ) GrantAccess (_ context.Context , req registry.GrantRequest ) (registry.GrantResult , error ) {
169+ data := map [string ][]byte {
170+ "endpoint" : []byte (d .cfg .Endpoint ),
171+ "bucket" : []byte (req .BucketyName ),
172+ "accessKeyID" : []byte (d .cfg .AccessKeyID ),
173+ "secretAccessKey" : []byte (d .cfg .SecretAccessKey ),
174+ }
175+ if d .cfg .Region != "" {
176+ data ["region" ] = []byte (d .cfg .Region )
177+ }
178+ return registry.GrantResult {
179+ SecretData : data ,
180+ Principal : "s3-root" ,
181+ Scoped : false ,
182+ }, nil
99183}
100184
185+ // RevokeAccess is a no-op in v1alpha1 (nothing to remove since
186+ // there is no per-access principal).
101187func (d * Driver ) RevokeAccess (_ context.Context , _ string ) error { return nil }
102188
103- // ValidateParameters honours the capability-gating contract even
104- // in the stub: jurisdiction is accepted only when implementation
105- // is r2; admission rejects mismatches so the SPEC behaviour
106- // matches once the EnsureBuckety side is implemented.
189+ // ValidateParameters honours the capability-gating contract:
190+ // jurisdiction is accepted only when implementation is r2.
107191func (d * Driver ) ValidateParameters (params map [string ]string ) error {
108192 for k , v := range params {
109193 switch k {
@@ -122,7 +206,7 @@ func (d *Driver) ValidateParameters(params map[string]string) error {
122206}
123207
124208// ValidateUpdateParameters: jurisdiction is set-at-create and
125- // immutable in v1alpha1 ; any change is a rejection.
209+ // immutable; any change is a rejection.
126210func (d * Driver ) ValidateUpdateParameters (oldParams , newParams map [string ]string ) error {
127211 if err := d .ValidateParameters (newParams ); err != nil {
128212 return err
@@ -144,3 +228,85 @@ func (d *Driver) ValidateAccessParameters(params map[string]string) error {
144228 return fmt .Errorf ("s3 v0.1 accepts no BucketyAccess parameters; got: %s" ,
145229 strings .Join (keys , ", " ))
146230}
231+
232+ // ---- internals ----
233+
234+ // bucketCreationConfig translates per-Buckety parameters into the
235+ // implementation-specific CreateBucketConfiguration. Returns nil
236+ // when no implementation-specific bucket-creation knobs apply.
237+ //
238+ // For AWS S3 the LocationConstraint follows the bucket's region
239+ // unless the region is us-east-1 (which uses an empty
240+ // LocationConstraint per AWS API rules).
241+ //
242+ // For R2 the jurisdiction parameter, when present, maps to the
243+ // LocationConstraint slot per Cloudflare's documented S3-interop
244+ // surface ("eu" places the bucket in the EU jurisdiction).
245+ //
246+ // For MinIO and VersityGW we deliberately omit
247+ // CreateBucketConfiguration entirely; both reject unexpected
248+ // LocationConstraint values on some versions.
249+ func bucketCreationConfig (impl , region string , params map [string ]string ) * s3types.CreateBucketConfiguration {
250+ switch impl {
251+ case "r2" :
252+ if j , ok := params ["jurisdiction" ]; ok && j != "" {
253+ return & s3types.CreateBucketConfiguration {
254+ LocationConstraint : s3types .BucketLocationConstraint (j ),
255+ }
256+ }
257+ case "aws" :
258+ if region != "" && region != "us-east-1" {
259+ return & s3types.CreateBucketConfiguration {
260+ LocationConstraint : s3types .BucketLocationConstraint (region ),
261+ }
262+ }
263+ }
264+ return nil
265+ }
266+
267+ // isAlreadyOwned reports whether err is the S3 service signalling
268+ // that the bucket already exists and is owned by the caller. We
269+ // treat both BucketAlreadyOwnedByYou and BucketAlreadyExists as
270+ // idempotent success: BucketAlreadyExists on AWS proper means a
271+ // global name collision, but on most other S3 implementations
272+ // (MinIO, VersityGW, R2 within an account) it surfaces for a
273+ // caller-owned bucket too and the next reconcile's drift check
274+ // will catch a genuinely foreign bucket via HeadBucket.
275+ func isAlreadyOwned (err error ) bool {
276+ var ae * s3types.BucketAlreadyOwnedByYou
277+ if errors .As (err , & ae ) {
278+ return true
279+ }
280+ var ex * s3types.BucketAlreadyExists
281+ if errors .As (err , & ex ) {
282+ return true
283+ }
284+ var api smithy.APIError
285+ if errors .As (err , & api ) {
286+ switch api .ErrorCode () {
287+ case "BucketAlreadyOwnedByYou" , "BucketAlreadyExists" :
288+ return true
289+ }
290+ }
291+ return false
292+ }
293+
294+ // isNotFound reports whether err is the S3 service signalling a
295+ // missing bucket. NoSuchBucket is the documented code; some
296+ // implementations (VersityGW, MinIO older releases) return
297+ // NotFound or a 404 status without a typed error, so we fall
298+ // back to APIError code matching.
299+ func isNotFound (err error ) bool {
300+ var nsb * s3types.NoSuchBucket
301+ if errors .As (err , & nsb ) {
302+ return true
303+ }
304+ var api smithy.APIError
305+ if errors .As (err , & api ) {
306+ switch api .ErrorCode () {
307+ case "NoSuchBucket" , "NotFound" :
308+ return true
309+ }
310+ }
311+ return false
312+ }
0 commit comments