Skip to content

Commit 6651105

Browse files
add support for automatic region extraction for S3 Express (#2104)
also add ListDirectoryBuckets API to test this PR ``` mc alias set s3express https://s3express-control.us-west-2.amazonaws.com xxx xxxx --api s3v4 mc ls s3express/ [2025-05-09 15:55:55 PDT] 0B harsha-test--usw2-az1--x-s3/ ```
1 parent 6b0f80f commit 6651105

File tree

11 files changed

+353
-28
lines changed

11 files changed

+353
-28
lines changed

api-datatypes.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ type BucketInfo struct {
3232
Name string `json:"name"`
3333
// Date the bucket was created.
3434
CreationDate time.Time `json:"creationDate"`
35+
// BucketRegion region where the bucket is present
36+
BucketRegion string `json:"bucketRegion"`
3537
}
3638

3739
// StringMap represents map with custom UnmarshalXML

api-list.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,64 @@ func (c *Client) ListBuckets(ctx context.Context) ([]BucketInfo, error) {
5858
return listAllMyBucketsResult.Buckets.Bucket, nil
5959
}
6060

61+
// ListDirectoryBuckets list all buckets owned by this authenticated user.
62+
//
63+
// This call requires explicit authentication, no anonymous requests are
64+
// allowed for listing buckets.
65+
//
66+
// api := client.New(....)
67+
// dirBuckets, err := api.ListDirectoryBuckets(context.Background())
68+
func (c *Client) ListDirectoryBuckets(ctx context.Context) (iter.Seq2[BucketInfo, error], error) {
69+
fetchBuckets := func(continuationToken string) ([]BucketInfo, string, error) {
70+
metadata := requestMetadata{contentSHA256Hex: emptySHA256Hex}
71+
metadata.queryValues = url.Values{}
72+
metadata.queryValues.Set("max-directory-buckets", "1000")
73+
if continuationToken != "" {
74+
metadata.queryValues.Set("continuation-token", continuationToken)
75+
}
76+
77+
// Execute GET on service.
78+
resp, err := c.executeMethod(ctx, http.MethodGet, metadata)
79+
defer closeResponse(resp)
80+
if err != nil {
81+
return nil, "", err
82+
}
83+
if resp != nil {
84+
if resp.StatusCode != http.StatusOK {
85+
return nil, "", httpRespToErrorResponse(resp, "", "")
86+
}
87+
}
88+
89+
results := listAllMyDirectoryBucketsResult{}
90+
if err = xmlDecoder(resp.Body, &results); err != nil {
91+
return nil, "", err
92+
}
93+
94+
return results.Buckets.Bucket, results.ContinuationToken, nil
95+
}
96+
97+
return func(yield func(BucketInfo, error) bool) {
98+
var continuationToken string
99+
for {
100+
buckets, token, err := fetchBuckets(continuationToken)
101+
if err != nil {
102+
yield(BucketInfo{}, err)
103+
return
104+
}
105+
for _, bucket := range buckets {
106+
if !yield(bucket, nil) {
107+
return
108+
}
109+
}
110+
if token == "" {
111+
// nothing to continue
112+
return
113+
}
114+
continuationToken = token
115+
}
116+
}, nil
117+
}
118+
61119
// Bucket List Operations.
62120
func (c *Client) listObjectsV2(ctx context.Context, bucketName string, opts ListObjectsOptions) iter.Seq[ObjectInfo] {
63121
// Default listing is delimited at "/"

api-s3-datatypes.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ type listAllMyBucketsResult struct {
3535
Owner owner
3636
}
3737

38+
// listAllMyDirectoryBucketsResult container for listDirectoryBuckets response.
39+
type listAllMyDirectoryBucketsResult struct {
40+
Buckets struct {
41+
Bucket []BucketInfo
42+
}
43+
ContinuationToken string
44+
}
45+
3846
// owner container for bucket owner information.
3947
type owner struct {
4048
DisplayName string

api.go

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -828,7 +828,7 @@ func (c *Client) newRequest(ctx context.Context, method string, metadata request
828828
// make sure to de-dup calls to credential services, this reduces
829829
// the overall load to the endpoint generating credential service.
830830
value, err, _ := c.credsGroup.Do(metadata.bucketName, func() (credentials.Value, error) {
831-
if s3utils.IsS3ExpressBucket(metadata.bucketName) {
831+
if s3utils.IsS3ExpressBucket(metadata.bucketName) && s3utils.IsAmazonEndpoint(*c.endpointURL) {
832832
return c.CreateSession(ctx, metadata.bucketName, SessionReadWrite)
833833
}
834834
// Get credentials from the configured credentials provider.
@@ -851,7 +851,7 @@ func (c *Client) newRequest(ctx context.Context, method string, metadata request
851851
sessionToken = value.SessionToken
852852
)
853853

854-
if s3utils.IsS3ExpressBucket(metadata.bucketName) {
854+
if s3utils.IsS3ExpressBucket(metadata.bucketName) && sessionToken != "" {
855855
req.Header.Set("x-amz-s3session-token", sessionToken)
856856
}
857857

@@ -940,8 +940,13 @@ func (c *Client) newRequest(ctx context.Context, method string, metadata request
940940
// Streaming signature is used by default for a PUT object request.
941941
// Additionally, we also look if the initialized client is secure,
942942
// if yes then we don't need to perform streaming signature.
943-
req = signer.StreamingSignV4(req, accessKeyID,
944-
secretAccessKey, sessionToken, location, metadata.contentLength, time.Now().UTC(), c.sha256Hasher())
943+
if s3utils.IsAmazonExpressRegionalEndpoint(*c.endpointURL) {
944+
req = signer.StreamingSignV4Express(req, accessKeyID,
945+
secretAccessKey, sessionToken, location, metadata.contentLength, time.Now().UTC(), c.sha256Hasher())
946+
} else {
947+
req = signer.StreamingSignV4(req, accessKeyID,
948+
secretAccessKey, sessionToken, location, metadata.contentLength, time.Now().UTC(), c.sha256Hasher())
949+
}
945950
default:
946951
// Set sha256 sum for signature calculation only with signature version '4'.
947952
shaHeader := unsignedPayload
@@ -956,8 +961,12 @@ func (c *Client) newRequest(ctx context.Context, method string, metadata request
956961
}
957962
req.Header.Set("X-Amz-Content-Sha256", shaHeader)
958963

959-
// Add signature version '4' authorization header.
960-
req = signer.SignV4Trailer(*req, accessKeyID, secretAccessKey, sessionToken, location, metadata.trailer)
964+
if s3utils.IsAmazonExpressRegionalEndpoint(*c.endpointURL) {
965+
req = signer.SignV4TrailerExpress(*req, accessKeyID, secretAccessKey, sessionToken, location, metadata.trailer)
966+
} else {
967+
// Add signature version '4' authorization header.
968+
req = signer.SignV4Trailer(*req, accessKeyID, secretAccessKey, sessionToken, location, metadata.trailer)
969+
}
961970
}
962971

963972
// Return request.
@@ -989,9 +998,18 @@ func (c *Client) makeTargetURL(bucketName, objectName, bucketLocation string, is
989998
host = c.s3AccelerateEndpoint
990999
} else {
9911000
// Do not change the host if the endpoint URL is a FIPS S3 endpoint or a S3 PrivateLink interface endpoint
992-
if !s3utils.IsAmazonFIPSEndpoint(*c.endpointURL) && !s3utils.IsAmazonPrivateLinkEndpoint(*c.endpointURL) && !s3utils.IsS3ExpressBucket(bucketName) {
993-
// Fetch new host based on the bucket location.
994-
host = getS3Endpoint(bucketLocation, c.s3DualstackEnabled)
1001+
if !s3utils.IsAmazonFIPSEndpoint(*c.endpointURL) && !s3utils.IsAmazonPrivateLinkEndpoint(*c.endpointURL) {
1002+
if s3utils.IsAmazonExpressRegionalEndpoint(*c.endpointURL) {
1003+
if bucketName == "" {
1004+
host = getS3ExpressEndpoint(bucketLocation, false)
1005+
} else {
1006+
// Fetch new host based on the bucket location.
1007+
host = getS3ExpressEndpoint(bucketLocation, s3utils.IsS3ExpressBucket(bucketName))
1008+
}
1009+
} else {
1010+
// Fetch new host based on the bucket location.
1011+
host = getS3Endpoint(bucketLocation, c.s3DualstackEnabled)
1012+
}
9951013
}
9961014
}
9971015
}

create-session.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ func (c *Client) CreateSession(ctx context.Context, bucketName string, sessionMo
7878
return credentials.Value{}, err
7979
}
8080

81+
if resp.StatusCode != http.StatusOK {
82+
return credentials.Value{}, httpRespToErrorResponse(resp, bucketName, "")
83+
}
84+
8185
credSession := &createSessionResult{}
8286
dec := xml.NewDecoder(resp.Body)
8387
if err = dec.Decode(credSession); err != nil {
@@ -103,12 +107,15 @@ func (c *Client) createSessionRequest(ctx context.Context, bucketName string, se
103107
// Set get bucket location always as path style.
104108
targetURL := *c.endpointURL
105109

110+
// Fetch new host based on the bucket location.
111+
host := getS3ExpressEndpoint(c.region, s3utils.IsS3ExpressBucket(bucketName))
112+
106113
// as it works in makeTargetURL method from api.go file
107-
if h, p, err := net.SplitHostPort(targetURL.Host); err == nil {
114+
if h, p, err := net.SplitHostPort(host); err == nil {
108115
if targetURL.Scheme == "http" && p == "80" || targetURL.Scheme == "https" && p == "443" {
109-
targetURL.Host = h
116+
host = h
110117
if ip := net.ParseIP(h); ip != nil && ip.To16() != nil {
111-
targetURL.Host = "[" + h + "]"
118+
host = "[" + h + "]"
112119
}
113120
}
114121
}
@@ -118,7 +125,7 @@ func (c *Client) createSessionRequest(ctx context.Context, bucketName string, se
118125
var urlStr string
119126

120127
if isVirtualStyle {
121-
urlStr = c.endpointURL.Scheme + "://" + bucketName + "." + targetURL.Host + "/?session"
128+
urlStr = c.endpointURL.Scheme + "://" + bucketName + "." + host + "/?session"
122129
} else {
123130
targetURL.Path = path.Join(bucketName, "") + "/"
124131
targetURL.RawQuery = urlValues.Encode()
@@ -170,6 +177,6 @@ func (c *Client) createSessionRequest(ctx context.Context, bucketName string, se
170177

171178
req.Header.Set("X-Amz-Content-Sha256", contentSha256)
172179
req.Header.Set("x-amz-create-session-mode", string(sessionMode))
173-
req = signer.SignV4(*req, accessKeyID, secretAccessKey, sessionToken, c.region)
180+
req = signer.SignV4Express(*req, accessKeyID, secretAccessKey, sessionToken, c.region)
174181
return req, nil
175182
}

s3-endpoints.go renamed to endpoints.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,66 @@ type awsS3Endpoint struct {
2222
dualstackEndpoint string
2323
}
2424

25+
type awsS3ExpressEndpoint struct {
26+
regionalEndpoint string
27+
zonalEndpoints []string
28+
}
29+
30+
var awsS3ExpressEndpointMap = map[string]awsS3ExpressEndpoint{
31+
"us-east-1": {
32+
"s3express-control.us-east-1.amazonaws.com",
33+
[]string{
34+
"s3express-use1-az4.us-east-1.amazonaws.com",
35+
"s3express-use1-az5.us-east-1.amazonaws.com",
36+
"3express-use1-az6.us-east-1.amazonaws.com",
37+
},
38+
},
39+
"us-east-2": {
40+
"s3express-control.us-east-2.amazonaws.com",
41+
[]string{
42+
"s3express-use2-az1.us-east-2.amazonaws.com",
43+
"s3express-use2-az2.us-east-2.amazonaws.com",
44+
},
45+
},
46+
"us-west-2": {
47+
"s3express-control.us-west-2.amazonaws.com",
48+
[]string{
49+
"s3express-usw2-az1.us-west-2.amazonaws.com",
50+
"s3express-usw2-az3.us-west-2.amazonaws.com",
51+
"s3express-usw2-az4.us-west-2.amazonaws.com",
52+
},
53+
},
54+
"ap-south-1": {
55+
"s3express-control.ap-south-1.amazonaws.com",
56+
[]string{
57+
"s3express-aps1-az1.ap-south-1.amazonaws.com",
58+
"s3express-aps1-az3.ap-south-1.amazonaws.com",
59+
},
60+
},
61+
"ap-northeast-1": {
62+
"s3express-control.ap-northeast-1.amazonaws.com",
63+
[]string{
64+
"s3express-apne1-az1.ap-northeast-1.amazonaws.com",
65+
"s3express-apne1-az4.ap-northeast-1.amazonaws.com",
66+
},
67+
},
68+
"eu-west-1": {
69+
"s3express-control.eu-west-1.amazonaws.com",
70+
[]string{
71+
"s3express-euw1-az1.eu-west-1.amazonaws.com",
72+
"s3express-euw1-az3.eu-west-1.amazonaws.com",
73+
},
74+
},
75+
"eu-north-1": {
76+
"s3express-control.eu-north-1.amazonaws.com",
77+
[]string{
78+
"s3express-eun1-az1.eu-north-1.amazonaws.com",
79+
"s3express-eun1-az2.eu-north-1.amazonaws.com",
80+
"s3express-eun1-az3.eu-north-1.amazonaws.com",
81+
},
82+
},
83+
}
84+
2585
// awsS3EndpointMap Amazon S3 endpoint map.
2686
var awsS3EndpointMap = map[string]awsS3Endpoint{
2787
"us-east-1": {
@@ -182,6 +242,19 @@ var awsS3EndpointMap = map[string]awsS3Endpoint{
182242
},
183243
}
184244

245+
// getS3ExpressEndpoint get Amazon S3 Express endpoing based on the region
246+
// optionally if zonal is set returns first zonal endpoint.
247+
func getS3ExpressEndpoint(region string, zonal bool) (endpoint string) {
248+
s3ExpEndpoint, ok := awsS3ExpressEndpointMap[region]
249+
if !ok {
250+
return ""
251+
}
252+
if zonal {
253+
return s3ExpEndpoint.zonalEndpoints[0]
254+
}
255+
return s3ExpEndpoint.regionalEndpoint
256+
}
257+
185258
// getS3Endpoint get Amazon S3 endpoint based on the bucket location.
186259
func getS3Endpoint(bucketLocation string, useDualstack bool) (endpoint string) {
187260
s3Endpoint, ok := awsS3EndpointMap[bucketLocation]

examples/s3/list-directory-buckets.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//go:build example
2+
// +build example
3+
4+
/*
5+
* MinIO Go Library for Amazon S3 Compatible Cloud Storage
6+
* Copyright 2015-2025 MinIO, Inc.
7+
*
8+
* Licensed under the Apache License, Version 2.0 (the "License");
9+
* you may not use this file except in compliance with the License.
10+
* You may obtain a copy of the License at
11+
*
12+
* http://www.apache.org/licenses/LICENSE-2.0
13+
*
14+
* Unless required by applicable law or agreed to in writing, software
15+
* distributed under the License is distributed on an "AS IS" BASIS,
16+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
* See the License for the specific language governing permissions and
18+
* limitations under the License.
19+
*/
20+
21+
package main
22+
23+
import (
24+
"context"
25+
"log"
26+
27+
"github.com/minio/minio-go/v7"
28+
"github.com/minio/minio-go/v7/pkg/credentials"
29+
)
30+
31+
func main() {
32+
// Note: YOUR-ACCESSKEYID and YOUR-SECRETACCESSKEY are
33+
// dummy values, please replace them with original values.
34+
35+
// Requests are always secure (HTTPS) by default. Set secure=false to enable insecure (HTTP) access.
36+
// This boolean value is the last argument for New().
37+
38+
// New returns an Amazon S3 compatible client object. API compatibility (v2 or v4) is automatically
39+
// determined based on the Endpoint value.
40+
s3Client, err := minio.New("s3express-control.us-east-1.amazonaws.com", &minio.Options{
41+
Creds: credentials.NewStaticV4("YOUR-ACCESSKEYID", "YOUR-SECRETACCESSKEY", ""),
42+
Secure: true,
43+
})
44+
if err != nil {
45+
log.Fatalln(err)
46+
}
47+
48+
dirBuckets, err := s3Client.ListDirectoryBuckets(context.Background())
49+
if err != nil {
50+
log.Fatalln(err)
51+
}
52+
for bucket, err := range dirBuckets {
53+
log.Println(bucket, err)
54+
}
55+
}

0 commit comments

Comments
 (0)