This is an automated email from the ASF dual-hosted git repository.
ivandika3 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git
The following commit(s) were added to refs/heads/master by this push:
new dac25dc6b15 HDDS-15450. Implement ListDirectoryBuckets S3 API for
Directory Buckets support (#10408)
dac25dc6b15 is described below
commit dac25dc6b15788c8e24aebc4f9d9eee65979839f
Author: Priyesh Karatha <[email protected]>
AuthorDate: Sat Jun 13 09:06:29 2026 +0530
HDDS-15450. Implement ListDirectoryBuckets S3 API for Directory Buckets
support (#10408)
---
.../hadoop/ozone/s3/awssdk/OzoneS3SDKTests.java | 1 +
.../ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java | 243 +++++++++++++++++++++
.../org/apache/hadoop/ozone/audit/S3GAction.java | 1 +
.../s3/commontypes/DirectoryBucketMetadata.java | 79 +++++++
.../s3/endpoint/ListDirectoryBucketsResponse.java | 69 ++++++
.../hadoop/ozone/s3/endpoint/RootEndpoint.java | 128 +++++++++++
.../org/apache/hadoop/ozone/s3/util/S3Consts.java | 6 +
.../hadoop/ozone/client/OzoneVolumeStub.java | 35 +--
.../s3/endpoint/TestListDirectoryBuckets.java | 187 ++++++++++++++++
9 files changed, 736 insertions(+), 13 deletions(-)
diff --git
a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/OzoneS3SDKTests.java
b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/OzoneS3SDKTests.java
index 665c9458f3c..885c3b60b17 100644
---
a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/OzoneS3SDKTests.java
+++
b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/OzoneS3SDKTests.java
@@ -48,4 +48,5 @@ public MiniOzoneCluster cluster() {
return getCluster();
}
}
+
}
diff --git
a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java
b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java
index 3a1df68154c..753e3826949 100644
---
a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java
+++
b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java
@@ -27,6 +27,7 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static software.amazon.awssdk.core.sync.RequestBody.fromString;
@@ -70,6 +71,7 @@
import org.apache.hadoop.ozone.client.OzoneKeyDetails;
import org.apache.hadoop.ozone.client.OzoneVolume;
import org.apache.hadoop.ozone.client.io.OzoneOutputStream;
+import org.apache.hadoop.ozone.om.helpers.BucketLayout;
import org.apache.hadoop.ozone.s3.S3ClientFactory;
import org.apache.hadoop.ozone.s3.awssdk.S3SDKTestUtils;
import org.apache.hadoop.ozone.s3.endpoint.S3Owner;
@@ -103,6 +105,7 @@
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.model.AbortMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.Bucket;
import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest;
import
software.amazon.awssdk.services.s3.model.CompleteMultipartUploadResponse;
import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload;
@@ -125,6 +128,8 @@
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
import software.amazon.awssdk.services.s3.model.ListBucketsResponse;
+import software.amazon.awssdk.services.s3.model.ListDirectoryBucketsRequest;
+import software.amazon.awssdk.services.s3.model.ListDirectoryBucketsResponse;
import software.amazon.awssdk.services.s3.model.ListMultipartUploadsRequest;
import software.amazon.awssdk.services.s3.model.ListObjectsRequest;
import software.amazon.awssdk.services.s3.model.ListObjectsResponse;
@@ -2701,4 +2706,242 @@ private void
verifyBucketOwnershipVerificationAccessDenied(Executable function)
assertEquals("Access Denied", exception.awsErrorDetails().errorCode());
}
}
+
+ /**
+ * Integration tests for the ListDirectoryBuckets S3 API (HDDS-15450).
+ *
+ * <p>These tests verify that GET / with the {@code max-directory-buckets}
query parameter
+ * correctly routes to the ListDirectoryBuckets handler and returns only FSO
(File System
+ * Optimized) buckets, while {@code ListBuckets} continues to return all
bucket types.
+ *
+ * <p>Note: passing {@code maxDirectoryBuckets} explicitly is required to
trigger the
+ * ListDirectoryBuckets routing in Ozone's S3 Gateway, because both
ListBuckets and
+ * ListDirectoryBuckets share the same {@code GET /} endpoint and must be
distinguished
+ * by either the {@code max-directory-buckets} query parameter or S3 Express
credential
+ * scope. See {@code RootEndpoint#isListDirectoryBucketsRequest()}.
+ */
+ @Nested
+ class ListDirectoryBucketsTests {
+
+ /**
+ * Verifies that only FSO (directory) buckets are returned, and OBS
buckets are excluded.
+ * Also verifies that the standard ListBuckets still returns all bucket
types.
+ */
+ @Test
+ public void testListDirectoryBucketsReturnsOnlyFSOBuckets() throws
Exception {
+ final String obsBucketName = uniqueObjectName();
+ final String fsoBucketName1 = uniqueObjectName();
+ final String fsoBucketName2 = uniqueObjectName();
+
+ s3Client.createBucket(b -> b.bucket(obsBucketName));
+ createFsoBucket(fsoBucketName1);
+ createFsoBucket(fsoBucketName2);
+ try {
+ ListDirectoryBucketsResponse response = s3Client.listDirectoryBuckets(
+ ListDirectoryBucketsRequest.builder()
+ .maxDirectoryBuckets(1000)
+ .build());
+
+ List<String> dirBucketNames = response.buckets().stream()
+ .map(Bucket::name)
+ .collect(Collectors.toList());
+
+ assertThat(dirBucketNames).contains(fsoBucketName1, fsoBucketName2);
+ assertThat(dirBucketNames).doesNotContain(obsBucketName);
+ } finally {
+ s3Client.deleteBucket(b -> b.bucket(obsBucketName));
+ deleteFsoBucket(fsoBucketName1);
+ deleteFsoBucket(fsoBucketName2);
+ }
+ }
+
+ /**
+ * Verifies that an empty result with no continuation token is returned
when no FSO
+ * buckets exist (only OBS buckets present).
+ */
+ @Test
+ public void testListDirectoryBucketsEmptyWhenNoFSOBuckets() throws
Exception {
+ final String obsBucketName = uniqueObjectName();
+
+ s3Client.createBucket(b -> b.bucket(obsBucketName));
+ try {
+ ListDirectoryBucketsResponse response = s3Client.listDirectoryBuckets(
+ ListDirectoryBucketsRequest.builder()
+ .maxDirectoryBuckets(1000)
+ .build());
+
+ List<String> dirBucketNames = response.buckets().stream()
+ .map(Bucket::name)
+ .collect(Collectors.toList());
+
+ assertThat(dirBucketNames).doesNotContain(obsBucketName);
+ assertNull(response.continuationToken());
+ } finally {
+ s3Client.deleteBucket(b -> b.bucket(obsBucketName));
+ }
+ }
+
+ /**
+ * Verifies pagination: listing FSO buckets page-by-page using {@code
maxDirectoryBuckets}
+ * and the returned continuation token, until all buckets are retrieved.
+ */
+ @Test
+ public void testListDirectoryBucketsPaginationReturnsAllBuckets() throws
Exception {
+ final int totalBuckets = 5;
+ final int pageSize = 2;
+ List<String> created = new ArrayList<>();
+
+ for (int i = 0; i < totalBuckets; i++) {
+ String name = uniqueObjectName();
+ createFsoBucket(name);
+ created.add(name);
+ }
+
+ try {
+ List<String> retrieved = new ArrayList<>();
+ String continuationToken = null;
+
+ do {
+ ListDirectoryBucketsRequest.Builder reqBuilder =
ListDirectoryBucketsRequest.builder()
+ .maxDirectoryBuckets(pageSize);
+ if (continuationToken != null) {
+ reqBuilder.continuationToken(continuationToken);
+ }
+
+ ListDirectoryBucketsResponse response =
s3Client.listDirectoryBuckets(reqBuilder.build());
+
+ response.buckets().stream()
+ .map(Bucket::name)
+ .filter(created::contains)
+ .forEach(retrieved::add);
+
+ continuationToken = response.continuationToken();
+ } while (continuationToken != null);
+
+ assertThat(retrieved).containsExactlyInAnyOrderElementsOf(created);
+ } finally {
+ for (String name : created) {
+ deleteFsoBucket(name);
+ }
+ }
+ }
+
+ /**
+ * Verifies that a single page returns no continuation token when fewer
buckets exist
+ * than the requested max.
+ */
+ @Test
+ public void
testListDirectoryBucketsNoContinuationTokenWhenResultFitsOnePage() throws
Exception {
+ final String fsoBucketName = uniqueObjectName();
+ createFsoBucket(fsoBucketName);
+
+ try {
+ ListDirectoryBucketsResponse response = s3Client.listDirectoryBuckets(
+ ListDirectoryBucketsRequest.builder()
+ .maxDirectoryBuckets(1000)
+ .build());
+
+ List<String> dirBucketNames = response.buckets().stream()
+ .map(Bucket::name)
+ .collect(Collectors.toList());
+
+ assertThat(dirBucketNames).contains(fsoBucketName);
+ assertNull(response.continuationToken(),
+ "No continuation token expected when all results fit on one page");
+ } finally {
+ deleteFsoBucket(fsoBucketName);
+ }
+ }
+
+ /**
+ * Verifies response fields: name, creationDate, bucketRegion, and
bucketArn are populated.
+ * The BucketArn must match the expected S3 Express ARN format.
+ */
+ @Test
+ public void testListDirectoryBucketsResponseFieldsArePopulated() throws
Exception {
+ final String fsoBucketName = uniqueObjectName();
+ createFsoBucket(fsoBucketName);
+
+ try {
+ ListDirectoryBucketsResponse response = s3Client.listDirectoryBuckets(
+ ListDirectoryBucketsRequest.builder()
+ .maxDirectoryBuckets(1000)
+ .build());
+
+ Bucket bucket = response.buckets().stream()
+ .filter(b -> b.name().equals(fsoBucketName))
+ .findFirst()
+ .orElse(null);
+
+ assertNotNull(bucket, "FSO bucket should be present in response");
+ assertEquals(fsoBucketName, bucket.name());
+ assertNotNull(bucket.creationDate(), "CreationDate must be set");
+ assertNotNull(bucket.bucketRegion(), "BucketRegion must be set");
+
+ // BucketArn should follow the S3 Express ARN format:
arn:aws:s3express:<region>:<accountId>:bucket/<name>
+ assertNotNull(bucket.bucketArn(), "BucketArn must be set");
+ assertThat(bucket.bucketArn())
+ .startsWith("arn:aws:s3express:")
+ .endsWith(":bucket/" + fsoBucketName);
+ } finally {
+ deleteFsoBucket(fsoBucketName);
+ }
+ }
+
+ /**
+ * Verifies that maxDirectoryBuckets=0 returns an empty result immediately.
+ */
+ @Test
+ public void testListDirectoryBucketsMaxZeroReturnsEmpty() throws Exception
{
+ final String fsoBucketName = uniqueObjectName();
+ createFsoBucket(fsoBucketName);
+
+ try {
+ ListDirectoryBucketsResponse response = s3Client.listDirectoryBuckets(
+ ListDirectoryBucketsRequest.builder()
+ .maxDirectoryBuckets(0)
+ .build());
+
+ assertEquals(0, response.buckets().size());
+ } finally {
+ deleteFsoBucket(fsoBucketName);
+ }
+ }
+
+ /**
+ * Verifies that creating an FSO bucket does not affect the standard
ListBuckets result —
+ * FSO buckets must also appear in ListBuckets (they are still
S3-accessible buckets).
+ */
+ @Test
+ public void testListBucketsIncludesFSOBuckets() throws Exception {
+ final String fsoBucketName = uniqueObjectName();
+ createFsoBucket(fsoBucketName);
+
+ try {
+ List<String> allBuckets = s3Client.listBuckets().buckets().stream()
+ .map(Bucket::name)
+ .collect(Collectors.toList());
+
+ assertThat(allBuckets).contains(fsoBucketName);
+ } finally {
+ deleteFsoBucket(fsoBucketName);
+ }
+ }
+
+ private void createFsoBucket(String bucketName) throws Exception {
+ try (OzoneClient ozoneClient = cluster.newClient()) {
+ OzoneVolume volume = ozoneClient.getObjectStore().getS3Volume();
+ volume.createBucket(bucketName, BucketArgs.newBuilder()
+ .setBucketLayout(BucketLayout.FILE_SYSTEM_OPTIMIZED)
+ .build());
+ }
+ }
+
+ private void deleteFsoBucket(String bucketName) throws Exception {
+ try (OzoneClient ozoneClient = cluster.newClient()) {
+ OzoneVolume volume = ozoneClient.getObjectStore().getS3Volume();
+ volume.deleteBucket(bucketName);
+ }
+ }
+ }
}
diff --git
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java
index 2561faa3226..969b2c04809 100644
---
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java
+++
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java
@@ -35,6 +35,7 @@ public enum S3GAction implements AuditAction {
//RootEndpoint
LIST_S3_BUCKETS,
+ LIST_DIRECTORY_BUCKETS,
//ObjectEndpoint
CREATE_MULTIPART_KEY,
diff --git
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/commontypes/DirectoryBucketMetadata.java
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/commontypes/DirectoryBucketMetadata.java
new file mode 100644
index 00000000000..dd0f50114e5
--- /dev/null
+++
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/commontypes/DirectoryBucketMetadata.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3.commontypes;
+
+import java.time.Instant;
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+
+/**
+ * Metadata for one S3 directory bucket in a ListDirectoryBuckets response.
+ *
+ * @see <a
href="https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListDirectoryBuckets.html">
+ * ListDirectoryBuckets</a>
+ */
+@XmlAccessorType(XmlAccessType.FIELD)
+public class DirectoryBucketMetadata {
+
+ @XmlElement(name = "Name")
+ private String name;
+
+ @XmlJavaTypeAdapter(IsoDateAdapter.class)
+ @XmlElement(name = "CreationDate")
+ private Instant creationDate;
+
+ @XmlElement(name = "BucketRegion")
+ private String bucketRegion;
+
+ @XmlElement(name = "BucketArn")
+ private String bucketArn;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public Instant getCreationDate() {
+ return creationDate;
+ }
+
+ public void setCreationDate(Instant creationDate) {
+ this.creationDate = creationDate;
+ }
+
+ public String getBucketRegion() {
+ return bucketRegion;
+ }
+
+ public void setBucketRegion(String bucketRegion) {
+ this.bucketRegion = bucketRegion;
+ }
+
+ public String getBucketArn() {
+ return bucketArn;
+ }
+
+ public void setBucketArn(String bucketArn) {
+ this.bucketArn = bucketArn;
+ }
+}
diff --git
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ListDirectoryBucketsResponse.java
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ListDirectoryBucketsResponse.java
new file mode 100644
index 00000000000..f68baa10e73
--- /dev/null
+++
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ListDirectoryBucketsResponse.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3.endpoint;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.util.ArrayList;
+import java.util.List;
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlElementWrapper;
+import javax.xml.bind.annotation.XmlRootElement;
+import org.apache.hadoop.ozone.s3.commontypes.DirectoryBucketMetadata;
+import org.apache.hadoop.ozone.s3.util.S3Consts;
+
+/**
+ * Response from the ListDirectoryBuckets API call.
+ *
+ * @see <a
href="https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListDirectoryBuckets.html">
+ * ListDirectoryBuckets</a>
+ */
+@XmlAccessorType(XmlAccessType.FIELD)
+@XmlRootElement(name = "ListDirectoryBucketsResult",
+ namespace = S3Consts.S3_XML_NAMESPACE)
+public class ListDirectoryBucketsResponse {
+
+ @XmlElementWrapper(name = "Buckets")
+ @XmlElement(name = "Bucket")
+ private List<DirectoryBucketMetadata> buckets = new ArrayList<>();
+
+ @XmlElement(name = "ContinuationToken")
+ private String continuationToken;
+
+ public List<DirectoryBucketMetadata> getBuckets() {
+ return buckets;
+ }
+
+ @VisibleForTesting
+ public int getBucketsNum() {
+ return buckets.size();
+ }
+
+ public void addBucket(DirectoryBucketMetadata bucket) {
+ buckets.add(bucket);
+ }
+
+ public String getContinuationToken() {
+ return continuationToken;
+ }
+
+ public void setContinuationToken(String continuationToken) {
+ this.continuationToken = continuationToken;
+ }
+}
diff --git
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/RootEndpoint.java
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/RootEndpoint.java
index 9e638a112a7..4cb52eaf3d5 100644
---
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/RootEndpoint.java
+++
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/RootEndpoint.java
@@ -17,6 +17,9 @@
package org.apache.hadoop.ozone.s3.endpoint;
+import static
org.apache.hadoop.ozone.s3.exception.S3ErrorTable.INVALID_ARGUMENT;
+import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.newError;
+
import java.io.IOException;
import java.util.Iterator;
import javax.ws.rs.GET;
@@ -25,7 +28,11 @@
import org.apache.hadoop.ozone.audit.S3GAction;
import org.apache.hadoop.ozone.client.OzoneBucket;
import org.apache.hadoop.ozone.s3.commontypes.BucketMetadata;
+import org.apache.hadoop.ozone.s3.commontypes.DirectoryBucketMetadata;
import org.apache.hadoop.ozone.s3.exception.OS3Exception;
+import org.apache.hadoop.ozone.s3.util.ContinueToken;
+import org.apache.hadoop.ozone.s3.util.S3Consts;
+import org.apache.hadoop.ozone.s3.util.S3Consts.QueryParams;
import org.apache.hadoop.util.Time;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -48,6 +55,101 @@ public class RootEndpoint extends EndpointBase {
@GET
public Response get()
throws OS3Exception, IOException {
+ if (isS3ExpressSignedRequest()) {
+ return listDirectoryBuckets();
+ }
+ return listAllBuckets();
+ }
+
+ /**
+ * AWS SDKs and CLI sign requests to S3 Express Regional endpoints
(s3express-control.<region>.amazonaws.com)
+ * using "s3express" as the SigV4 service name in the credential scope
+ * (<date>/<region>/s3express/aws4_request), even though this is not
explicitly documented
+ * on the ListDirectoryBuckets API page.
+ */
+ private boolean isS3ExpressSignedRequest() {
+ if (signatureInfo == null) {
+ return false;
+ }
+ String credentialScope = signatureInfo.getCredentialScope();
+ if (credentialScope == null || credentialScope.isEmpty()) {
+ return false;
+ }
+ String[] parts = credentialScope.split("/");
+ return parts.length >= 3 && S3Consts.S3_EXPRESS_SERVICE.equals(parts[2]);
+ }
+
+ /**
+ * Rest endpoint to list FSO (directory) buckets owned by the current user.
+ */
+ private Response listDirectoryBuckets()
+ throws OS3Exception, IOException {
+ long startNanos = Time.monotonicNowNanos();
+ boolean auditSuccess = true;
+ try {
+ final String continueToken =
queryParams().get(QueryParams.CONTINUATION_TOKEN);
+ int maxDirectoryBuckets =
+ queryParams().getInt(QueryParams.MAX_DIRECTORY_BUCKETS,
S3Consts.MAX_DIRECTORY_BUCKETS_LIMIT);
+ maxDirectoryBuckets = validateMaxDirectoryBuckets(maxDirectoryBuckets);
+
+ ListDirectoryBucketsResponse response = new
ListDirectoryBucketsResponse();
+ String previousBucket = null;
+ if (continueToken != null) {
+ previousBucket =
ContinueToken.decodeFromString(continueToken).getLastKey();
+ }
+
+ Iterator<? extends OzoneBucket> bucketIterator;
+ try {
+ if (previousBucket == null) {
+ bucketIterator = listS3Buckets(null, volume -> { });
+ } else {
+ bucketIterator = listS3Buckets(null, previousBucket, volume -> { });
+ }
+ } catch (Exception e) {
+ getMetrics().updateListS3BucketsFailureStats(startNanos);
+ throw e;
+ }
+
+ String bucketRegion = resolveBucketRegion();
+ String accountId = S3Owner.DEFAULT_S3OWNER_ID;
+ int count = 0;
+ String lastBucketName = null;
+ while (bucketIterator.hasNext() && count < maxDirectoryBuckets) {
+ OzoneBucket bucket = bucketIterator.next();
+ if (!bucket.getBucketLayout().isFileSystemOptimized()) {
+ continue;
+ }
+ DirectoryBucketMetadata metadata = new DirectoryBucketMetadata();
+ metadata.setName(bucket.getName());
+ metadata.setCreationDate(bucket.getCreationTime());
+ metadata.setBucketRegion(bucketRegion);
+ metadata.setBucketArn(buildDirectoryBucketArn(bucketRegion, accountId,
+ bucket.getName()));
+ response.addBucket(metadata);
+ lastBucketName = bucket.getName();
+ count++;
+ }
+
+ if (lastBucketName != null && bucketIterator.hasNext()) {
+ response.setContinuationToken(
+ new ContinueToken(lastBucketName, null).encodeToString());
+ }
+
+ getMetrics().updateListS3BucketsSuccessStats(startNanos);
+ return Response.ok(response).build();
+ } catch (Exception ex) {
+ auditSuccess = false;
+ auditReadFailure(S3GAction.LIST_DIRECTORY_BUCKETS, ex);
+ throw ex;
+ } finally {
+ if (auditSuccess) {
+ auditReadSuccess(S3GAction.LIST_DIRECTORY_BUCKETS);
+ }
+ }
+ }
+
+ private Response listAllBuckets()
+ throws OS3Exception, IOException {
long startNanos = Time.monotonicNowNanos();
boolean auditSuccess = true;
try {
@@ -81,4 +183,30 @@ public Response get()
}
}
}
+
+ private int validateMaxDirectoryBuckets(int maxDirectoryBuckets) throws
OS3Exception {
+ if (maxDirectoryBuckets < 0) {
+ throw newError(INVALID_ARGUMENT, "max-directory-buckets must be >= 0");
+ }
+ return Math.min(maxDirectoryBuckets, S3Consts.MAX_DIRECTORY_BUCKETS_LIMIT);
+ }
+
+ private String resolveBucketRegion() {
+ if (signatureInfo != null) {
+ String credentialScope = signatureInfo.getCredentialScope();
+ if (credentialScope != null && !credentialScope.isEmpty()) {
+ String[] parts = credentialScope.split("/");
+ if (parts.length >= 2 && !parts[1].isEmpty()) {
+ return parts[1];
+ }
+ }
+ }
+ return S3Consts.DEFAULT_S3_REGION;
+ }
+
+ static String buildDirectoryBucketArn(String region, String accountId,
+ String bucketName) {
+ return String.format("arn:aws:s3express:%s:%s:bucket/%s",
+ region, accountId, bucketName);
+ }
}
diff --git
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Consts.java
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Consts.java
index 157324d4a24..c898ea78e4a 100644
---
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Consts.java
+++
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Consts.java
@@ -109,6 +109,11 @@ public final class S3Consts {
public static final String IF_UNMODIFIED_SINCE_HEADER =
"If-Unmodified-Since";
+ // Constants related to S3 Express / ListDirectoryBuckets
+ public static final int MAX_DIRECTORY_BUCKETS_LIMIT = 1000;
+ public static final String DEFAULT_S3_REGION = "us-east-1";
+ public static final String S3_EXPRESS_SERVICE = "s3express";
+
//Never Constructed
private S3Consts() {
@@ -133,6 +138,7 @@ public static final class QueryParams {
// GetBucketLocation is not implemented
public static final String LOCATION = "location";
public static final String MARKER = "marker";
+ public static final String MAX_DIRECTORY_BUCKETS = "max-directory-buckets";
public static final String MAX_KEYS = "max-keys";
public static final String MAX_PARTS = "max-parts";
public static final String MAX_UPLOADS = "max-uploads";
diff --git
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneVolumeStub.java
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneVolumeStub.java
index b19187d45c0..2919c85203b 100644
---
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneVolumeStub.java
+++
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneVolumeStub.java
@@ -19,11 +19,13 @@
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
import org.apache.hadoop.hdds.client.DefaultReplicationConfig;
import org.apache.hadoop.hdds.client.RatisReplicationConfig;
import org.apache.hadoop.hdds.protocol.StorageType;
@@ -144,15 +146,7 @@ public OzoneBucket getBucket(String bucketName) throws
IOException {
@Override
public Iterator<? extends OzoneBucket> listBuckets(String bucketPrefix) {
- return buckets.values()
- .stream()
- .filter(bucket -> {
- if (bucketPrefix != null) {
- return bucket.getName().startsWith(bucketPrefix);
- } else {
- return true;
- }
- })
+ return listBucketsStream(bucketPrefix, null)
.collect(Collectors.toList())
.iterator();
}
@@ -160,14 +154,29 @@ public Iterator<? extends OzoneBucket> listBuckets(String
bucketPrefix) {
@Override
public Iterator<? extends OzoneBucket> listBuckets(String bucketPrefix,
String prevBucket) {
- return buckets.values()
- .stream()
- .filter(bucket -> bucket.getName().compareTo(prevBucket) > 0)
- .filter(bucket -> bucket.getName().startsWith(bucketPrefix))
+ return listBucketsStream(bucketPrefix, prevBucket)
.collect(Collectors.toList())
.iterator();
}
+ private Stream<? extends OzoneBucket> listBucketsStream(String bucketPrefix,
+ String prevBucket) {
+ return buckets.values()
+ .stream()
+ .filter(bucket -> matchesBucketPrefix(bucket, bucketPrefix))
+ .filter(bucket -> prevBucket == null
+ || bucket.getName().compareTo(prevBucket) > 0)
+ .sorted(Comparator.comparing(OzoneBucket::getName));
+ }
+
+ private static boolean matchesBucketPrefix(OzoneBucket bucket,
+ String bucketPrefix) {
+ if (bucketPrefix != null) {
+ return bucket.getName().startsWith(bucketPrefix);
+ }
+ return true;
+ }
+
@Override
public void deleteBucket(String bucketName) throws IOException {
if (!buckets.containsKey(bucketName)) {
diff --git
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestListDirectoryBuckets.java
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestListDirectoryBuckets.java
new file mode 100644
index 00000000000..cc967a5d606
--- /dev/null
+++
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestListDirectoryBuckets.java
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3.endpoint;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.apache.hadoop.ozone.OzoneConfigKeys;
+import org.apache.hadoop.ozone.client.BucketArgs;
+import org.apache.hadoop.ozone.client.OzoneClient;
+import org.apache.hadoop.ozone.client.OzoneClientStub;
+import org.apache.hadoop.ozone.client.OzoneVolume;
+import org.apache.hadoop.ozone.om.helpers.BucketLayout;
+import org.apache.hadoop.ozone.s3.commontypes.DirectoryBucketMetadata;
+import org.apache.hadoop.ozone.s3.exception.OS3Exception;
+import org.apache.hadoop.ozone.s3.signature.SignatureInfo;
+import org.apache.hadoop.ozone.s3.util.S3Consts.QueryParams;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for ListDirectoryBuckets (FSO bucket listing).
+ */
+public class TestListDirectoryBuckets {
+
+ private OzoneClient clientStub;
+ private RootEndpoint rootEndpoint;
+
+ private static final String DEFAULT_VOLUME =
OzoneConfigKeys.OZONE_S3_VOLUME_NAME_DEFAULT;
+
+ @BeforeEach
+ public void setup() throws Exception {
+ clientStub = new OzoneClientStub();
+ rootEndpoint = EndpointBuilder.newRootEndpointBuilder()
+ .setClient(clientStub)
+ .setSignatureInfo(new SignatureInfo.Builder(SignatureInfo.Version.V4)
+ .setCredentialScope("20260101/us-west-2/s3express/aws4_request")
+ .build())
+ .build();
+
+ clientStub.getObjectStore().createVolume(DEFAULT_VOLUME);
+ }
+
+ @Test
+ public void testListDirectoryBucketsEmpty() throws Exception {
+
rootEndpoint.queryParamsForTest().setInt(QueryParams.MAX_DIRECTORY_BUCKETS,
1000);
+
+ ListDirectoryBucketsResponse response =
+ (ListDirectoryBucketsResponse) rootEndpoint.get().getEntity();
+
+ assertEquals(0, response.getBucketsNum());
+ assertNull(response.getContinuationToken());
+ }
+
+ @Test
+ public void testListDirectoryBucketsFiltersNonFsoBuckets() throws Exception {
+ clientStub.getObjectStore().createS3Bucket("obs-bucket");
+ createFsoBucket("fso-bucket-1");
+ createFsoBucket("fso-bucket-2");
+
+
rootEndpoint.queryParamsForTest().setInt(QueryParams.MAX_DIRECTORY_BUCKETS,
1000);
+ ListDirectoryBucketsResponse response =
+ (ListDirectoryBucketsResponse) rootEndpoint.get().getEntity();
+
+ assertEquals(2, response.getBucketsNum());
+ assertNull(response.getContinuationToken());
+
+ DirectoryBucketMetadata first = response.getBuckets().get(0);
+ assertEquals("fso-bucket-1", first.getName());
+ assertEquals("us-west-2", first.getBucketRegion());
+ assertEquals(
+ RootEndpoint.buildDirectoryBucketArn("us-west-2",
S3Owner.DEFAULT_S3OWNER_ID,
+ "fso-bucket-1"),
+ first.getBucketArn());
+ assertNotNull(first.getCreationDate());
+ }
+
+ @Test
+ public void testListDirectoryBucketsPagination() throws Exception {
+ for (int i = 0; i < 5; i++) {
+ createFsoBucket("fso-bucket-" + i);
+ }
+
+
rootEndpoint.queryParamsForTest().setInt(QueryParams.MAX_DIRECTORY_BUCKETS, 2);
+ ListDirectoryBucketsResponse response =
+ (ListDirectoryBucketsResponse) rootEndpoint.get().getEntity();
+
+ assertEquals(2, response.getBucketsNum());
+ assertEquals("fso-bucket-0", response.getBuckets().get(0).getName());
+ assertEquals("fso-bucket-1", response.getBuckets().get(1).getName());
+ assertNotNull(response.getContinuationToken());
+
+ rootEndpoint.queryParamsForTest().set(QueryParams.CONTINUATION_TOKEN,
+ response.getContinuationToken());
+ response = (ListDirectoryBucketsResponse) rootEndpoint.get().getEntity();
+
+ assertEquals(2, response.getBucketsNum());
+ assertEquals("fso-bucket-2", response.getBuckets().get(0).getName());
+ assertEquals("fso-bucket-3", response.getBuckets().get(1).getName());
+ assertNotNull(response.getContinuationToken());
+
+ rootEndpoint.queryParamsForTest().set(QueryParams.CONTINUATION_TOKEN,
+ response.getContinuationToken());
+ response = (ListDirectoryBucketsResponse) rootEndpoint.get().getEntity();
+
+ assertEquals(1, response.getBucketsNum());
+ assertEquals("fso-bucket-4", response.getBuckets().get(0).getName());
+ assertNull(response.getContinuationToken());
+ }
+
+ @Test
+ public void testListDirectoryBucketsZeroMaxBuckets() throws Exception {
+ createFsoBucket("fso-bucket");
+
+
rootEndpoint.queryParamsForTest().setInt(QueryParams.MAX_DIRECTORY_BUCKETS, 0);
+ ListDirectoryBucketsResponse response =
+ (ListDirectoryBucketsResponse) rootEndpoint.get().getEntity();
+
+ assertEquals(0, response.getBucketsNum());
+ assertNull(response.getContinuationToken());
+ }
+
+ @Test
+ public void testListDirectoryBucketsInvalidMaxBuckets() {
+
rootEndpoint.queryParamsForTest().setInt(QueryParams.MAX_DIRECTORY_BUCKETS, -1);
+ assertThrows(OS3Exception.class, () -> rootEndpoint.get());
+ }
+
+ @Test
+ public void testListDirectoryBucketsWithS3ExpressSigningOnly() throws
Exception {
+ clientStub.getObjectStore().createS3Bucket("obs-bucket");
+ createFsoBucket("fso-bucket");
+
+ rootEndpoint = EndpointBuilder.newRootEndpointBuilder()
+ .setClient(clientStub)
+ .setSignatureInfo(new SignatureInfo.Builder(SignatureInfo.Version.V4)
+ .setCredentialScope("20260101/aws-global/s3express/aws4_request")
+ .build())
+ .build();
+
+ ListDirectoryBucketsResponse response =
+ (ListDirectoryBucketsResponse) rootEndpoint.get().getEntity();
+
+ assertEquals(1, response.getBucketsNum());
+ assertEquals("fso-bucket", response.getBuckets().get(0).getName());
+ assertEquals("aws-global", response.getBuckets().get(0).getBucketRegion());
+ assertNull(response.getContinuationToken());
+ }
+
+ @Test
+ public void testListDirectoryBucketsCapsAtLimit() throws Exception {
+ for (int i = 0; i < 3; i++) {
+ createFsoBucket("fso-bucket-" + i);
+ }
+
+
rootEndpoint.queryParamsForTest().setInt(QueryParams.MAX_DIRECTORY_BUCKETS,
2000);
+ ListDirectoryBucketsResponse response =
+ (ListDirectoryBucketsResponse) rootEndpoint.get().getEntity();
+
+ assertEquals(3, response.getBucketsNum());
+ assertNull(response.getContinuationToken());
+ }
+
+ private void createFsoBucket(String bucketName) throws Exception {
+ OzoneVolume volume = clientStub.getObjectStore().getVolume(DEFAULT_VOLUME);
+ volume.createBucket(bucketName, BucketArgs.newBuilder()
+ .setBucketLayout(BucketLayout.FILE_SYSTEM_OPTIMIZED)
+ .build());
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]