Skip to content

Commit 8ea9e13

Browse files
Yolean k8s-qa (buckety maintainer)claude
andcommitted
s3 driver: implement EnsureBuckety/DeleteBuckety/GrantAccess
Replaces the v1alpha1 STUB in pkg/drivers/s3 with an aws-sdk-go-v2 client that talks to any S3-compatible backend. Closes the open gap the ystack maintainer reported on integration: blobs/S3 bucket provisioning can now move off the y-kustomize Job onto buckety. The actual S3 surface used by the controller is narrow - CreateBucket (idempotent on BucketAlreadyOwnedByYou and BucketAlreadyExists), DeleteBucket (idempotent on NoSuchBucket), no per-access IAM in v1alpha1 - so this is ~190 lines of driver code plus the AWS SDK v2 dependency set. GrantAccess emits the flat Secret keys per SPEC §Secret output > s3 driver: endpoint, bucket (resource-type key), region (when non-empty), accessKeyID, secretAccessKey. CI matrix is unchanged: VersityGW and MinIO. The SPEC's client-library compatibility bet (§Drivers in v1alpha1) covers GCS, AWS S3, R2, and Hetzner without per-implementation e2e jobs. Capability gating for r2's jurisdiction parameter is wired through to CreateBucketConfiguration.LocationConstraint; mainstream implementations omit CreateBucketConfiguration entirely because both MinIO and VersityGW reject unexpected LocationConstraint values on some versions. Version bumped 0.0.1 -> 0.1.0 to signal exit from STUB; the ldflags override pattern matches kadm. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 172e1ca commit 8ea9e13

4 files changed

Lines changed: 235 additions & 33 deletions

File tree

deploy/kustomize/release/kustomization.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ kind: Kustomization
77
resources:
88
- ../base
99
images:
10-
- digest: sha256:e7de9d78243f90b1b58d6176d3e15422c335e465b73d1fc9c97f6a93bf835ce2
10+
- digest: sha256:72fe666f4f5c3dc1de41ea5c7fd0efc3be7dab6458eb3d9c150e2e33906bb0c4
1111
name: ghcr.io/yolean/buckety-controller
1212
newName: ghcr.io/yolean/buckety-controller
13-
newTag: 20260531T201852Z
13+
newTag: 20260616T083639Z

go.mod

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ go 1.26.1
44

55
require (
66
github.com/Yolean/y-cluster v0.4.6
7+
github.com/aws/aws-sdk-go-v2 v1.42.0
8+
github.com/aws/aws-sdk-go-v2/credentials v1.19.24
9+
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3
10+
github.com/aws/smithy-go v1.27.2
711
github.com/twmb/franz-go v1.21.2
812
github.com/twmb/franz-go/pkg/kadm v1.18.0
913
go.uber.org/zap v1.27.1
@@ -15,6 +19,14 @@ require (
1519
)
1620

1721
require (
22+
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
23+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
24+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
25+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
26+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
27+
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
28+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
29+
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect
1830
github.com/beorn7/perks v1.0.1 // indirect
1931
github.com/cespare/xxhash/v2 v2.3.0 // indirect
2032
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect

go.sum

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,30 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
22
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
33
github.com/Yolean/y-cluster v0.4.6 h1:Gb7+Jh0x4QQM1BaX0U05EYzLPW0a06Rka4aqfhBwj44=
44
github.com/Yolean/y-cluster v0.4.6/go.mod h1:Y4zCWRzmGiaL6tBqFhfiRiiJgkORDdVTydQr9ORxgqE=
5+
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
6+
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
7+
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
8+
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
9+
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
10+
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
11+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
12+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
13+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
14+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
15+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
16+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
17+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
18+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
19+
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
20+
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
21+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
22+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
23+
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc=
24+
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
25+
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 h1:JRseEu/vIDMaWis4bSw0QbXL+cvIGc1XnX076H5ZXLE=
26+
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
27+
github.com/aws/smithy-go v1.27.2 h1:y9NPmSE6am6LjEFPfqHqG/jJk7AauQvhCJONKh7kpzk=
28+
github.com/aws/smithy-go v1.27.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
529
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
630
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
731
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=

pkg/drivers/s3/driver.go

Lines changed: 197 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
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.
1111
package s3
1212

1313
import (
@@ -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.
2632
const 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

3841
func 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.
82104
type Driver struct {
83-
cfg *Config
105+
cfg *Config
106+
client *awss3.Client
84107
}
85108

86109
func (d *Driver) Name() string { return DriverName }
87110
func (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).
101187
func (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.
107191
func (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.
126210
func (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

Comments
 (0)