Repository: googleapis/java-storage
Pull request #2739 (2024-09-26 00:44:33)

test: integration StorageControl and HNS bucket into integration runner
`StorageControlClient` is now able to be `@Inject`ed to a test class (both PROD and TEST_BENCH)

New `BucketType.HNS` added and able to be used with `@BucketFixture` in all the usual places.

Update `BucketCleaner` to be HNS and managed folders aware.

Remove dependency from google-cloud-storage-control -> google-cloud-storage. Instead, move ITFolderTest into google-cloud-storage and have google-cloud-storage depend on google-cloud-storage-control with scope: test.




Patch:
diff --git a/.github/workflows/hermetic_library_generation.yaml b/.github/workflows/hermetic_library_generation.yaml
index 6997a7b4..fc55d042 100644
--- a/.github/workflows/hermetic_library_generation.yaml
+++ b/.github/workflows/hermetic_library_generation.yaml
@@ -39,7 +39,7 @@ jobs:
       with:
         fetch-depth: 0
         token: ${{ secrets.CLOUD_JAVA_BOT_TOKEN }}
-    - uses: googleapis/sdk-platform-java/.github/scripts@v2.46.0
+    - uses: googleapis/sdk-platform-java/.github/scripts@v2.46.1
       if: env.SHOULD_RUN == 'true'
       with:
         base_ref: ${{ github.base_ref }}
diff --git a/google-cloud-storage-control/pom.xml b/google-cloud-storage-control/pom.xml
index 9f5970b6..881ff87f 100644
--- a/google-cloud-storage-control/pom.xml
+++ b/google-cloud-storage-control/pom.xml
@@ -74,37 +74,6 @@
         <dependency>
             <groupId>com.google.api.grpc</groupId>
             <artifactId>grpc-google-cloud-storage-control-v2</artifactId>
-            <scope>test</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.google.apis</groupId>
-            <artifactId>google-api-services-storage</artifactId>
-            <scope>test</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.google.cloud</groupId>
-            <artifactId>google-cloud-storage</artifactId>
-            <scope>test</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.google.auth</groupId>
-            <artifactId>google-auth-library-oauth2-http</artifactId>
-            <scope>test</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.google.http-client</groupId>
-            <artifactId>google-http-client-jackson2</artifactId>
-            <scope>test</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.google.http-client</groupId>
-            <artifactId>google-http-client</artifactId>
-            <scope>test</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.google.cloud</groupId>
-            <artifactId>google-cloud-core-http</artifactId>
-            <scope>test</scope>
         </dependency>
     </dependencies>
 
diff --git a/google-cloud-storage/pom.xml b/google-cloud-storage/pom.xml
index 1d654a85..78877ee2 100644
--- a/google-cloud-storage/pom.xml
+++ b/google-cloud-storage/pom.xml
@@ -262,6 +262,16 @@
         </exclusion>
       </exclusions>
     </dependency>
+    <dependency>
+      <groupId>com.google.api.grpc</groupId>
+      <artifactId>proto-google-cloud-storage-control-v2</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.google.cloud</groupId>
+      <artifactId>google-cloud-storage-control</artifactId>
+      <scope>test</scope>
+    </dependency>
     <dependency>
       <groupId>org.mockito</groupId>
       <artifactId>mockito-core</artifactId>


Test patch:
diff --git a/google-cloud-storage-control/src/test/java/com/google/storage/control/v2/ITFoldersTest.java b/google-cloud-storage-control/src/test/java/com/google/storage/control/v2/ITFoldersTest.java
deleted file mode 100644
index c8609e1c..00000000
--- a/google-cloud-storage-control/src/test/java/com/google/storage/control/v2/ITFoldersTest.java
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * Copyright 2024 Google LLC
- *
- * Licensed 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
- *
- *      https://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 com.google.storage.control.v2;
-
-import com.google.api.client.http.HttpRequestInitializer;
-import com.google.api.client.http.HttpTransport;
-import com.google.api.client.json.jackson2.JacksonFactory;
-import com.google.api.services.storage.Storage;
-import com.google.api.services.storage.model.Bucket;
-import com.google.cloud.http.HttpTransportOptions;
-import com.google.cloud.storage.StorageOptions;
-import java.util.UUID;
-import org.junit.Assert;
-import org.junit.Test;
-
-public class ITFoldersTest {
-  @Test
-  public void createFolderTest() throws Exception {
-    // TODO: Update this with manual client implmentation of HNS enabled when published.
-    // TODO: Refactor to use Storage test infrastructure if extended.
-    StorageOptions option = StorageOptions.http().build();
-    HttpTransportOptions httpTransportOptions = (HttpTransportOptions) option.getTransportOptions();
-    HttpTransport transport = httpTransportOptions.getHttpTransportFactory().create();
-    HttpRequestInitializer initializer = httpTransportOptions.getHttpRequestInitializer(option);
-    Storage storage =
-        new Storage.Builder(transport, new JacksonFactory(), initializer)
-            .setApplicationName("test")
-            .build();
-    String bucketName = "hns-b-gcs-grpc-team-test-" + UUID.randomUUID();
-    Bucket createdBucket = null;
-    try {
-      Bucket bucketConfig =
-          new Bucket()
-              .setName(bucketName)
-              .setHierarchicalNamespace(new Bucket.HierarchicalNamespace().setEnabled(true))
-              .setIamConfiguration(
-                  new Bucket.IamConfiguration()
-                      .setUniformBucketLevelAccess(
-                          new Bucket.IamConfiguration.UniformBucketLevelAccess().setEnabled(true)));
-      createdBucket = storage.buckets().insert(option.getProjectId(), bucketConfig).execute();
-      StorageControlClient storageControlClient = StorageControlClient.create();
-      String folderId = "foldername/";
-      Folder folder =
-          storageControlClient.createFolder(
-              CreateFolderRequest.newBuilder()
-                  .setParent(BucketName.format("_", bucketName))
-                  .setFolderId(folderId)
-                  .build());
-      Assert.assertEquals(folder.getName(), FolderName.format("_", bucketName, folderId));
-    } finally {
-      if (createdBucket != null) {
-        storage.buckets().delete(bucketName);
-      }
-    }
-  }
-}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/BucketCleaner.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/BucketCleaner.java
index c4dba11f..91ef5829 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/BucketCleaner.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/BucketCleaner.java
@@ -16,16 +16,35 @@
 
 package com.google.cloud.storage.it;
 
+import com.google.api.gax.grpc.GrpcCallContext;
 import com.google.api.gax.paging.Page;
+import com.google.api.gax.rpc.ApiException;
+import com.google.api.gax.rpc.FailedPreconditionException;
 import com.google.cloud.storage.Blob;
 import com.google.cloud.storage.Storage;
+import com.google.cloud.storage.Storage.BlobField;
 import com.google.cloud.storage.Storage.BlobListOption;
 import com.google.cloud.storage.Storage.BlobSourceOption;
 import com.google.cloud.storage.Storage.BucketSourceOption;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.storage.control.v2.BucketName;
+import com.google.storage.control.v2.DeleteFolderRequest;
+import com.google.storage.control.v2.DeleteManagedFolderRequest;
+import com.google.storage.control.v2.Folder;
+import com.google.storage.control.v2.GetStorageLayoutRequest;
+import com.google.storage.control.v2.ListFoldersRequest;
+import com.google.storage.control.v2.ListManagedFoldersRequest;
+import com.google.storage.control.v2.StorageControlClient;
+import com.google.storage.control.v2.StorageLayout;
+import com.google.storage.control.v2.StorageLayoutName;
+import java.util.Collections;
+import java.util.Comparator;
 import java.util.List;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
 
 public final class BucketCleaner {
@@ -48,12 +67,9 @@ public final class BucketCleaner {
                           b.getName(),
                           s.delete(b.getBlobId(), BlobSourceOption.userProject(projectId))))
               .collect(Collectors.toList());
-      List<DeleteResult> failedDeletes =
-          deleteResults.stream().filter(r -> !r.success).collect(Collectors.toList());
-      failedDeletes.forEach(
-          r -> LOGGER.warning(String.format("Failed to delete object %s/%s", bucketName, r.name)));
+      boolean anyFailedObjectDeletes = getIfAnyFailedAndReport(bucketName, deleteResults, "object");
 
-      if (failedDeletes.isEmpty()) {
+      if (!anyFailedObjectDeletes) {
         s.delete(bucketName, BucketSourceOption.userProject(projectId));
       } else {
         LOGGER.warning("Unable to delete bucket due to previous failed object deletes");
@@ -64,6 +80,161 @@ public final class BucketCleaner {
     }
   }
 
+  public static void doCleanup(String bucketName, Storage s, StorageControlClient ctrl) {
+    LOGGER.warning("Starting bucket cleanup: " + bucketName);
+    String projectId = s.getOptions().getProjectId();
+    try {
+      // TODO: probe bucket existence, a bad test could have deleted the bucket
+      Page<Blob> page1 =
+          s.list(
+              bucketName,
+              BlobListOption.userProject(projectId),
+              BlobListOption.versions(true),
+              BlobListOption.fields(BlobField.NAME));
+
+      List<DeleteResult> objectResults =
+          StreamSupport.stream(page1.iterateAll().spliterator(), false)
+              .map(
+                  b ->
+                      new DeleteResult(
+                          b.getName(),
+                          s.delete(b.getBlobId(), BlobSourceOption.userProject(projectId))))
+              .collect(Collectors.toList());
+      boolean anyFailedObjectDelete = getIfAnyFailedAndReport(bucketName, objectResults, "object");
+      boolean anyFailedFolderDelete = false;
+      boolean anyFailedManagedFolderDelete = false;
+
+      GrpcCallContext grpcCallContext =
+          GrpcCallContext.createDefault()
+              .withExtraHeaders(
+                  ImmutableMap.of("x-goog-user-project", ImmutableList.of(projectId)));
+      if (!anyFailedObjectDelete) {
+        BucketName parent = BucketName.of("_", bucketName);
+        StorageLayout storageLayout =
+            ctrl.getStorageLayoutCallable()
+                .call(
+                    GetStorageLayoutRequest.newBuilder()
+                        .setName(
+                            StorageLayoutName.of(parent.getProject(), parent.getBucket())
+                                .toString())
+                        .build(),
+                    grpcCallContext);
+
+        List<DeleteResult> folderDeletes;
+        if (storageLayout.hasHierarchicalNamespace()
+            && storageLayout.getHierarchicalNamespace().getEnabled()) {
+          folderDeletes =
+              StreamSupport.stream(
+                      ctrl.listFoldersPagedCallable()
+                          .call(
+                              ListFoldersRequest.newBuilder().setParent(parent.toString()).build(),
+                              grpcCallContext)
+                          .iterateAll()
+                          .spliterator(),
+                      false)
+                  .collect(Collectors.toList())
+                  .stream()
+                  .sorted(Collections.reverseOrder(Comparator.comparing(Folder::getName)))
+                  .map(
+                      folder -> {
+                        String formatted = String.format("folder = %s", folder.getName());
+                        LOGGER.warning(formatted);
+                        boolean success = true;
+                        try {
+                          ctrl.deleteFolderCallable()
+                              .call(
+                                  DeleteFolderRequest.newBuilder()
+                                      .setName(folder.getName())
+                                      .build(),
+                                  grpcCallContext);
+                        } catch (ApiException e) {
+                          success = false;
+                        }
+                        return new DeleteResult(folder.getName(), success);
+                      })
+                  .collect(Collectors.toList());
+        } else {
+          folderDeletes = ImmutableList.of();
+        }
+
+        List<DeleteResult> managedFolderDeletes;
+        try {
+          managedFolderDeletes =
+              StreamSupport.stream(
+                      ctrl.listManagedFoldersPagedCallable()
+                          .call(
+                              ListManagedFoldersRequest.newBuilder()
+                                  .setParent(parent.toString())
+                                  .build(),
+                              grpcCallContext)
+                          .iterateAll()
+                          .spliterator(),
+                      false)
+                  .map(
+                      managedFolder -> {
+                        String formatted =
+                            String.format("managedFolder = %s", managedFolder.getName());
+                        LOGGER.warning(formatted);
+                        boolean success = true;
+                        try {
+                          ctrl.deleteManagedFolderCallable()
+                              .call(
+                                  DeleteManagedFolderRequest.newBuilder()
+                                      .setName(managedFolder.getName())
+                                      .build(),
+                                  grpcCallContext);
+                        } catch (ApiException e) {
+                          success = false;
+                        }
+                        return new DeleteResult(managedFolder.getName(), success);
+                      })
+                  .collect(Collectors.toList());
+        } catch (FailedPreconditionException fpe) {
+          // FAILED_PRECONDITION: Uniform bucket-level access is required to be enabled on the
+          //   bucket in order to perform this operation. Read more at
+          //   https://cloud.google.com/storage/docs/uniform-bucket-level-access
+          managedFolderDeletes = ImmutableList.of();
+        }
+
+        anyFailedFolderDelete = getIfAnyFailedAndReport(bucketName, folderDeletes, "folder");
+        anyFailedManagedFolderDelete =
+            getIfAnyFailedAndReport(bucketName, managedFolderDeletes, "managed folder");
+      }
+
+      List<String> failed =
+          Stream.of(
+                  anyFailedObjectDelete ? "object" : "",
+                  anyFailedFolderDelete ? "folder" : "",
+                  anyFailedManagedFolderDelete ? "managed folder" : "")
+              .filter(ss -> !ss.isEmpty())
+              .collect(Collectors.toList());
+
+      if (!anyFailedObjectDelete && !anyFailedFolderDelete && !anyFailedManagedFolderDelete) {
+        s.delete(bucketName, BucketSourceOption.userProject(projectId));
+      } else {
+        LOGGER.warning(
+            String.format(
+                "Unable to delete bucket %s due to previous failed %s deletes",
+                bucketName, failed));
+      }
+
+      LOGGER.warning("Bucket cleanup complete: " + bucketName);
+    } catch (Exception e) {
+      LOGGER.log(Level.SEVERE, e, () -> "Error during bucket cleanup.");
+    }
+  }
+
+  private static boolean getIfAnyFailedAndReport(
+      String bucketName, List<DeleteResult> deleteResults, String resourceType) {
+    List<DeleteResult> failedDeletes =
+        deleteResults.stream().filter(r -> !r.success).collect(Collectors.toList());
+    failedDeletes.forEach(
+        r ->
+            LOGGER.warning(
+                String.format("Failed to delete %s %s/%s", resourceType, bucketName, r.name)));
+    return !failedDeletes.isEmpty();
+  }
+
   private static final class DeleteResult {
     private final String name;
     private final boolean success;
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITFoldersTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITFoldersTest.java
new file mode 100644
index 00000000..a5953366
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITFoldersTest.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2024 Google LLC
+ *
+ * Licensed 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 com.google.cloud.storage.it;
+
+import static com.google.cloud.storage.TestUtils.assertAll;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.cloud.storage.BucketInfo;
+import com.google.cloud.storage.it.runner.StorageITRunner;
+import com.google.cloud.storage.it.runner.annotations.Backend;
+import com.google.cloud.storage.it.runner.annotations.BucketFixture;
+import com.google.cloud.storage.it.runner.annotations.BucketType;
+import com.google.cloud.storage.it.runner.annotations.Inject;
+import com.google.cloud.storage.it.runner.annotations.SingleBackend;
+import com.google.cloud.storage.it.runner.registry.Generator;
+import com.google.storage.control.v2.BucketName;
+import com.google.storage.control.v2.CreateFolderRequest;
+import com.google.storage.control.v2.Folder;
+import com.google.storage.control.v2.FolderName;
+import com.google.storage.control.v2.StorageControlClient;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(StorageITRunner.class)
+@SingleBackend(Backend.PROD)
+public class ITFoldersTest {
+
+  @Inject public StorageControlClient ctrl;
+
+  @Inject
+  @BucketFixture(BucketType.HNS)
+  public BucketInfo bucket;
+
+  @Inject public Generator generator;
+
+  @Test
+  public void createFolder() throws Exception {
+    String folderId = generator.randomObjectName() + "/";
+    String bucketName = bucket.getName();
+    Folder folder =
+        ctrl.createFolder(
+            CreateFolderRequest.newBuilder()
+                .setParent(BucketName.format("_", bucketName))
+                .setFolderId(folderId)
+                .build());
+    assertAll(
+        () -> assertThat(folder.getName()).isEqualTo(FolderName.format("_", bucketName, folderId)),
+        () -> assertThat(folder.getMetageneration()).isGreaterThan(0));
+  }
+}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/TemporaryBucket.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/TemporaryBucket.java
index 54ccfd92..0109c3d2 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/TemporaryBucket.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/TemporaryBucket.java
@@ -23,22 +23,27 @@ import com.google.cloud.storage.BucketInfo;
 import com.google.cloud.storage.Storage;
 import com.google.cloud.storage.conformance.retry.CleanupStrategy;
 import com.google.common.base.Preconditions;
+import com.google.storage.control.v2.StorageControlClient;
 import java.time.Duration;
+import org.checkerframework.checker.nullness.qual.Nullable;
 
 public final class TemporaryBucket implements AutoCloseable {
 
   private final BucketInfo bucket;
   private final Storage storage;
+  @Nullable private final StorageControlClient ctrl;
   private final Duration cleanupTimeout;
   private final CleanupStrategy cleanupStrategy;
 
   private TemporaryBucket(
       BucketInfo bucket,
       Storage storage,
+      @Nullable StorageControlClient ctrl,
       Duration cleanupTimeout,
       CleanupStrategy cleanupStrategy) {
     this.bucket = bucket;
     this.storage = storage;
+    this.ctrl = ctrl;
     this.cleanupTimeout = cleanupTimeout;
     this.cleanupStrategy = cleanupStrategy;
   }
@@ -51,7 +56,11 @@ public final class TemporaryBucket implements AutoCloseable {
   @Override
   public void close() throws Exception {
     if (cleanupStrategy == CleanupStrategy.ALWAYS) {
-      BucketCleaner.doCleanup(bucket.getName(), storage);
+      if (ctrl != null) {
+        BucketCleaner.doCleanup(bucket.getName(), storage, ctrl);
+      } else {
+        BucketCleaner.doCleanup(bucket.getName(), storage);
+      }
     }
   }
 
@@ -65,6 +74,7 @@ public final class TemporaryBucket implements AutoCloseable {
     private Duration cleanupTimeoutDuration;
     private BucketInfo bucketInfo;
     private Storage storage;
+    private StorageControlClient ctrl;
 
     private Builder() {
       this.cleanupStrategy = CleanupStrategy.ALWAYS;
@@ -91,6 +101,11 @@ public final class TemporaryBucket implements AutoCloseable {
       return this;
     }
 
+    public Builder setStorageControlClient(StorageControlClient ctrl) {
+      this.ctrl = ctrl;
+      return this;
+    }
+
     public TemporaryBucket build() {
       Preconditions.checkArgument(
           cleanupStrategy != CleanupStrategy.ONLY_ON_SUCCESS, "Unable to detect success.");
@@ -98,7 +113,8 @@ public final class TemporaryBucket implements AutoCloseable {
       Bucket b = s.create(requireNonNull(bucketInfo, "bucketInfo must be non null"));
 
       // intentionally drop from Bucket to BucketInfo to ensure not leaking the Storage instance
-      return new TemporaryBucket(b.asBucketInfo(), s, cleanupTimeoutDuration, cleanupStrategy);
+      return new TemporaryBucket(
+          b.asBucketInfo(), s, ctrl, cleanupTimeoutDuration, cleanupStrategy);
     }
   }
 }
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/annotations/BucketType.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/annotations/BucketType.java
index a38bbf01..c3335235 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/annotations/BucketType.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/annotations/BucketType.java
@@ -21,5 +21,7 @@ public enum BucketType {
   /** A default bucket created using all GCS defaults */
   DEFAULT,
   /** A bucket created using all GCS defaults except that it has requester_pays enabled. */
-  REQUESTER_PAYS
+  REQUESTER_PAYS,
+  /** A bucket created with Hierarchical Namespace enabled */
+  HNS
 }
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/BackendResources.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/BackendResources.java
index 28a7b877..237a5c67 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/BackendResources.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/BackendResources.java
@@ -17,12 +17,15 @@
 package com.google.cloud.storage.it.runner.registry;
 
 import static com.google.cloud.storage.it.runner.registry.RegistryApplicabilityPredicate.backendIs;
+import static com.google.cloud.storage.it.runner.registry.RegistryApplicabilityPredicate.bucketTypeIs;
 import static com.google.cloud.storage.it.runner.registry.RegistryApplicabilityPredicate.isDefaultBucket;
-import static com.google.cloud.storage.it.runner.registry.RegistryApplicabilityPredicate.isRequesterPaysBucket;
 import static com.google.cloud.storage.it.runner.registry.RegistryApplicabilityPredicate.transportAndBackendAre;
 
+import com.google.api.gax.core.NoCredentialsProvider;
 import com.google.cloud.NoCredentials;
 import com.google.cloud.storage.BucketInfo;
+import com.google.cloud.storage.BucketInfo.HierarchicalNamespace;
+import com.google.cloud.storage.BucketInfo.IamConfiguration;
 import com.google.cloud.storage.GrpcStorageOptions;
 import com.google.cloud.storage.HttpStorageOptions;
 import com.google.cloud.storage.Storage;
@@ -30,8 +33,14 @@ import com.google.cloud.storage.StorageOptions;
 import com.google.cloud.storage.TransportCompatibility.Transport;
 import com.google.cloud.storage.it.GrpcPlainRequestLoggingInterceptor;
 import com.google.cloud.storage.it.runner.annotations.Backend;
+import com.google.cloud.storage.it.runner.annotations.BucketType;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
+import com.google.storage.control.v2.StorageControlClient;
+import com.google.storage.control.v2.StorageControlSettings;
+import com.google.storage.control.v2.stub.StorageControlStubSettings;
+import java.io.IOException;
+import java.net.URI;
 import java.util.UUID;
 
 /** The set of resources which are defined for a single backend. */
@@ -122,13 +131,53 @@ final class BackendResources implements ManagedLifecycle {
                       .build();
               return new StorageInstance(built, protectedBucketNames);
             });
+    TestRunScopedInstance<StorageControlInstance> ctrl =
+        TestRunScopedInstance.of(
+            "STORAGE_CONTROL_" + backend.name(),
+            () -> {
+              StorageControlSettings.Builder builder;
+              switch (backend) {
+                case TEST_BENCH:
+                  String baseUri = Registry.getInstance().testBench().getBaseUri();
+                  URI uri = URI.create(baseUri);
+                  String endpoint = String.format("%s:%d", uri.getHost(), uri.getPort());
+                  builder =
+                      StorageControlSettings.newBuilder()
+                          .setCredentialsProvider(NoCredentialsProvider.create())
+                          .setEndpoint(endpoint)
+                          .setTransportChannelProvider(
+                              StorageControlStubSettings.defaultGrpcTransportProviderBuilder()
+                                  .setInterceptorProvider(
+                                      GrpcPlainRequestLoggingInterceptor.getInterceptorProvider())
+                                  .setEndpoint(endpoint)
+                                  .build());
+                  break;
+                default: // PROD, java8 doesn't have exhaustive checking for enum switch
+                  builder =
+                      StorageControlSettings.newBuilder()
+                          .setTransportChannelProvider(
+                              StorageControlStubSettings.defaultGrpcTransportProviderBuilder()
+                                  .setInterceptorProvider(
+                                      GrpcPlainRequestLoggingInterceptor.getInterceptorProvider())
+                                  .build());
+                  break;
+              }
+
+              try {
+                StorageControlSettings settings = builder.build();
+                return new StorageControlInstance(settings);
+              } catch (IOException e) {
+                throw new RuntimeException(e);
+              }
+            });
     TestRunScopedInstance<BucketInfoShim> bucket =
         TestRunScopedInstance.of(
             "BUCKET_" + backend.name(),
             () -> {
               String bucketName = String.format("java-storage-grpc-%s", UUID.randomUUID());
               protectedBucketNames.add(bucketName);
-              return new BucketInfoShim(BucketInfo.of(bucketName), storageJson.get().getStorage());
+              return new BucketInfoShim(
+                  BucketInfo.of(bucketName), storageJson.get().getStorage(), ctrl.get().getCtrl());
             });
     TestRunScopedInstance<BucketInfoShim> bucketRp =
         TestRunScopedInstance.of(
@@ -138,7 +187,26 @@ final class BackendResources implements ManagedLifecycle {
               protectedBucketNames.add(bucketName);
               return new BucketInfoShim(
                   BucketInfo.newBuilder(bucketName).setRequesterPays(true).build(),
-                  storageJson.get().getStorage());
+                  storageJson.get().getStorage(),
+                  ctrl.get().getCtrl());
+            });
+    TestRunScopedInstance<BucketInfoShim> bucketHns =
+        TestRunScopedInstance.of(
+            "BUCKET_HNS_" + backend.name(),
+            () -> {
+              String bucketName = String.format("java-storage-grpc-hns-%s", UUID.randomUUID());
+              protectedBucketNames.add(bucketName);
+              return new BucketInfoShim(
+                  BucketInfo.newBuilder(bucketName)
+                      .setHierarchicalNamespace(
+                          HierarchicalNamespace.newBuilder().setEnabled(true).build())
+                      .setIamConfiguration(
+                          IamConfiguration.newBuilder()
+                              .setIsUniformBucketLevelAccessEnabled(true)
+                              .build())
+                      .build(),
+                  storageJson.get().getStorage(),
+                  ctrl.get().getCtrl());
             });
     TestRunScopedInstance<ObjectsFixture> objectsFixture =
         TestRunScopedInstance.of(
@@ -149,6 +217,12 @@ final class BackendResources implements ManagedLifecycle {
             "OBJECTS_FIXTURE_REQUESTER_PAYS_" + backend.name(),
             () ->
                 new ObjectsFixture(storageJson.get().getStorage(), bucketRp.get().getBucketInfo()));
+    TestRunScopedInstance<ObjectsFixture> objectsFixtureHns =
+        TestRunScopedInstance.of(
+            "OBJECTS_FIXTURE_HNS_" + backend.name(),
+            () ->
+                new ObjectsFixture(
+                    storageJson.get().getStorage(), bucketHns.get().getBucketInfo()));
     TestRunScopedInstance<KmsFixture> kmsFixture =
         TestRunScopedInstance.of(
             "KMS_FIXTURE_" + backend.name(), () -> KmsFixture.of(storageJson.get().getStorage()));
@@ -161,8 +235,17 @@ final class BackendResources implements ManagedLifecycle {
                 40, Storage.class, storageJson, transportAndBackendAre(Transport.HTTP, backend)),
             RegistryEntry.of(
                 50, Storage.class, storageGrpc, transportAndBackendAre(Transport.GRPC, backend)),
+            RegistryEntry.of(55, StorageControlClient.class, ctrl, backendIs(backend)),
+            RegistryEntry.of(
+                60,
+                BucketInfo.class,
+                bucketRp,
+                backendIs(backend).and(bucketTypeIs(BucketType.REQUESTER_PAYS))),
             RegistryEntry.of(
-                60, BucketInfo.class, bucketRp, backendIs(backend).and(isRequesterPaysBucket())),
+                61,
+                BucketInfo.class,
+                bucketHns,
+                backendIs(backend).and(bucketTypeIs(BucketType.HNS))),
             RegistryEntry.of(
                 70, BucketInfo.class, bucket, backendIs(backend).and(isDefaultBucket())),
             RegistryEntry.of(
@@ -174,7 +257,12 @@ final class BackendResources implements ManagedLifecycle {
                 90,
                 ObjectsFixture.class,
                 objectsFixtureRp,
-                backendIs(backend).and(isRequesterPaysBucket())),
+                backendIs(backend).and(bucketTypeIs(BucketType.REQUESTER_PAYS))),
+            RegistryEntry.of(
+                91,
+                ObjectsFixture.class,
+                objectsFixtureHns,
+                backendIs(backend).and(bucketTypeIs(BucketType.HNS))),
             RegistryEntry.of(100, KmsFixture.class, kmsFixture, backendIs(backend))));
   }
 }
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/BucketInfoShim.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/BucketInfoShim.java
index fba6e1c8..13da4e46 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/BucketInfoShim.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/BucketInfoShim.java
@@ -19,18 +19,21 @@ package com.google.cloud.storage.it.runner.registry;
 import com.google.cloud.storage.BucketInfo;
 import com.google.cloud.storage.Storage;
 import com.google.cloud.storage.it.BucketCleaner;
+import com.google.storage.control.v2.StorageControlClient;
 
 /** Shim to lift a BucketInfo to be a managed bucket instance */
 final class BucketInfoShim implements ManagedLifecycle {
 
   private final BucketInfo bucketInfo;
   private final Storage s;
+  private final StorageControlClient ctrl;
 
   private BucketInfo createdBucket;
 
-  BucketInfoShim(BucketInfo bucketInfo, Storage s) {
+  BucketInfoShim(BucketInfo bucketInfo, Storage s, StorageControlClient ctrl) {
     this.bucketInfo = bucketInfo;
     this.s = s;
+    this.ctrl = ctrl;
   }
 
   public BucketInfo getBucketInfo() {
@@ -49,6 +52,6 @@ final class BucketInfoShim implements ManagedLifecycle {
 
   @Override
   public void stop() {
-    BucketCleaner.doCleanup(bucketInfo.getName(), s);
+    BucketCleaner.doCleanup(bucketInfo.getName(), s, ctrl);
   }
 }
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/RegistryApplicabilityPredicate.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/RegistryApplicabilityPredicate.java
index d30c31fa..43bcd809 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/RegistryApplicabilityPredicate.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/RegistryApplicabilityPredicate.java
@@ -88,10 +88,8 @@ interface RegistryApplicabilityPredicate {
     };
   }
 
-  static RegistryApplicabilityPredicate isRequesterPaysBucket() {
+  static RegistryApplicabilityPredicate bucketTypeIs(BucketType bucketType) {
     return annotatedWith(BucketFixture.class)
-        .and(
-            (ff, intersection) ->
-                ff.getAnnotation(BucketFixture.class).value() == BucketType.REQUESTER_PAYS);
+        .and((ff, intersection) -> ff.getAnnotation(BucketFixture.class).value() == bucketType);
   }
 }
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/StorageControlInstance.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/StorageControlInstance.java
new file mode 100644
index 00000000..aaa1abf8
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/StorageControlInstance.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2024 Google LLC
+ *
+ * Licensed 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 com.google.cloud.storage.it.runner.registry;
+
+import com.google.storage.control.v2.StorageControlClient;
+import com.google.storage.control.v2.StorageControlSettings;
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+final class StorageControlInstance implements ManagedLifecycle {
+
+  private final StorageControlSettings settings;
+
+  private StorageControlClient ctrl;
+
+  StorageControlInstance(StorageControlSettings settings) {
+    this.settings = settings;
+  }
+
+  StorageControlClient getCtrl() {
+    return ctrl;
+  }
+
+  @Override
+  public Object get() {
+    return ctrl;
+  }
+
+  @Override
+  public void start() {
+    try {
+      ctrl = StorageControlClient.create(settings);
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Override
+  public void stop() {
+    try {
+      ctrl.shutdownNow();
+      ctrl.awaitTermination(30, TimeUnit.SECONDS);
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      throw new RuntimeException(e);
+    }
+  }
+}