Copilot commented on code in PR #14160:
URL: https://github.com/apache/cloudstack/pull/14160#discussion_r4010913333


##########
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:
##########
@@ -0,0 +1,582 @@
+/*
+ * 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.
+ */
+// SPDX-License-Identifier: Apache-2.0
+package org.apache.cloudstack.storage.datastore.driver;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.inject.Inject;
+
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
+import org.apache.cloudstack.storage.datastore.db.ObjectStoreDao;
+import org.apache.cloudstack.storage.datastore.db.ObjectStoreDetailsDao;
+import org.apache.cloudstack.storage.datastore.db.ObjectStoreVO;
+import org.apache.cloudstack.storage.datastore.util.SeaweedFSObjectStoreUtil;
+import org.apache.cloudstack.storage.object.BaseObjectStoreDriverImpl;
+import org.apache.cloudstack.storage.object.Bucket;
+import org.apache.cloudstack.storage.object.BucketObject;
+
+import com.amazonaws.AmazonClientException;
+import com.amazonaws.services.identitymanagement.AmazonIdentityManagement;
+import com.amazonaws.services.identitymanagement.model.AccessKey;
+import com.amazonaws.services.identitymanagement.model.AccessKeyMetadata;
+import com.amazonaws.services.identitymanagement.model.CreateAccessKeyRequest;
+import com.amazonaws.services.identitymanagement.model.CreateAccessKeyResult;
+import com.amazonaws.services.identitymanagement.model.CreateUserRequest;
+import com.amazonaws.services.identitymanagement.model.DeleteAccessKeyRequest;
+import 
com.amazonaws.services.identitymanagement.model.EntityAlreadyExistsException;
+import com.amazonaws.services.identitymanagement.model.ListAccessKeysRequest;
+import com.amazonaws.services.identitymanagement.model.PutUserPolicyRequest;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.model.AccessControlList;
+import com.amazonaws.services.s3.model.BucketPolicy;
+import com.amazonaws.services.s3.model.BucketVersioningConfiguration;
+import com.amazonaws.services.s3.model.CreateBucketRequest;
+import com.amazonaws.services.s3.model.DeleteBucketPolicyRequest;
+import com.amazonaws.services.s3.model.GetBucketPolicyRequest;
+import com.amazonaws.services.s3.model.SSEAlgorithm;
+import com.amazonaws.services.s3.model.ServerSideEncryptionByDefault;
+import com.amazonaws.services.s3.model.ServerSideEncryptionConfiguration;
+import com.amazonaws.services.s3.model.ServerSideEncryptionRule;
+import com.amazonaws.services.s3.model.SetBucketEncryptionRequest;
+import com.amazonaws.services.s3.model.SetBucketVersioningConfigurationRequest;
+import com.cloud.agent.api.to.BucketTO;
+import com.cloud.agent.api.to.DataStoreTO;
+import com.cloud.storage.BucketVO;
+import com.cloud.storage.dao.BucketDao;
+import com.cloud.user.Account;
+import com.cloud.user.AccountDetailsDao;
+import com.cloud.user.dao.AccountDao;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+/**
+ * SeaweedFS object store driver.
+ *
+ * Bucket operations use the AWS S3 SDK v1 (path-style access, 
endpoint-pinned).
+ * User/credential management uses the AWS IAM SDK v1, since SeaweedFS exposes 
a
+ * standard AWS IAM-compatible API. No proprietary admin client is needed.
+ *
+ * Modeled on CloudianHyperStoreObjectStoreDriverImpl, which uses the same
+ * S3 + IAM SDK pair.
+ */
+public class SeaweedFSObjectStoreDriverImpl extends BaseObjectStoreDriverImpl {
+
+    @Inject
+    AccountDao _accountDao;
+
+    @Inject
+    AccountDetailsDao _accountDetailsDao;
+
+    @Inject
+    ObjectStoreDao _storeDao;
+
+    @Inject
+    BucketDao _bucketDao;
+
+    @Inject
+    ObjectStoreDetailsDao _storeDetailsDao;
+
+    private static final String ACS_PREFIX = "acs";
+
+    @Override
+    public DataStoreTO getStoreTO(DataStore store) {
+        return null;
+    }
+
+    /**
+     * Get the SeaweedFS IAM user name for the given CloudStack account.
+     * Uses the account UUID prefixed with "acs-" for namespacing.
+     */
+    protected String getUserNameForAccount(Account account) {
+        return String.format("%s-%s", ACS_PREFIX, account.getUuid());
+    }
+
+    /**
+     * Create the IAM user for the CloudStack account if it doesn't exist,
+     * attach the restricted S3 policy, and ensure the account has a usable
+     * IAM access key persisted in its account details.
+     *
+     * <p>If a previously stored access key is still present in IAM, it is
+     * reused rather than rotated. A new key is only created when no stored
+     * key exists or the stored key is no longer found in IAM; in the latter
+     * case any unmanaged (leftover) keys for the user are deleted first to
+     * avoid hitting IAM access-key limits. This keeps bucket records that
+     * reference the stored credentials valid across repeated calls.
+     *
+     * @return true if the user exists or was created, false on failure.
+     */
+    @Override
+    public boolean createUser(long accountId, long storeId) {
+        Account account = _accountDao.findById(accountId);
+        if (account == null) {
+            logger.error("Account {} not found", accountId);
+            return false;
+        }
+        String userName = getUserNameForAccount(account);
+        AmazonIdentityManagement iamClient = getIAMClient(storeId);
+
+        // Create the IAM user if it doesn't already exist
+        try {
+            iamClient.createUser(new CreateUserRequest(userName));
+            logger.info("Created IAM user {} for account {}", userName, 
account.getAccountName());
+        } catch (EntityAlreadyExistsException e) {
+            logger.debug("IAM user {} already exists", userName);
+        }
+
+        // Attach the restricted S3 policy (idempotent — overwrites if present)
+        iamClient.putUserPolicy(new PutUserPolicyRequest(userName,
+                "CloudStackPolicy", SeaweedFSObjectStoreUtil.IAM_USER_POLICY));
+
+        // Reuse the stored access key if it is still present in IAM; only
+        // create a new one when no usable key exists.
+        Map<String, String> details = 
_accountDetailsDao.findDetails(accountId);
+        String accessKeyDetailKey = 
SeaweedFSObjectStoreUtil.keyAccessKey(storeId);
+        String secretKeyDetailKey = 
SeaweedFSObjectStoreUtil.keySecretKey(storeId);
+        String storedAccessKeyId = details.get(accessKeyDetailKey);
+        if (storedAccessKeyId != null && iamAccessKeyExists(iamClient, 
userName, storedAccessKeyId)) {
+            logger.debug("Reusing existing IAM access key {} for user {}", 
storedAccessKeyId, userName);
+            return true;
+        }
+
+        // The stored key is missing or no longer in IAM. Clean up any
+        // unmanaged leftover keys before creating a replacement so we do not
+        // accumulate keys and hit IAM access-key limits.
+        deleteUnmanagedAccessKeys(iamClient, userName, storedAccessKeyId);
+
+        CreateAccessKeyResult result = iamClient.createAccessKey(
+                new CreateAccessKeyRequest().withUserName(userName));
+        AccessKey key = result.getAccessKey();
+
+        // Persist the credentials in the account details (namespaced by 
storeId)
+        details.put(accessKeyDetailKey, key.getAccessKeyId());
+        details.put(secretKeyDetailKey, key.getSecretAccessKey());
+        _accountDetailsDao.persist(accountId, details);
+
+        // Update existing bucket records for this account/store with the new
+        // credentials so previously created buckets don't keep handing out
+        // the old (now invalid) key pair.
+        updateAccountBucketCredentials(storeId, accountId, key);
+
+        logger.info("Created IAM credentials {} for user {}", 
key.getAccessKeyId(), userName);
+        return true;
+    }
+
+    /**
+     * Update the IAM credentials on all BucketVO rows for this store/account
+     * so previously created buckets reflect the new (rotated) key pair.
+     * Mirrors 
CloudianHyperStoreObjectStoreDriverImpl.updateAccountBucketCredentials.
+     */
+    private void updateAccountBucketCredentials(long storeId, long accountId, 
AccessKey iamCredential) {
+        List<BucketVO> bucketList = 
_bucketDao.listByObjectStoreIdAndAccountId(storeId, accountId);
+        for (BucketVO bucketVO : bucketList) {
+            logger.info("Updating accountId={} bucket {} with new IAM 
credentials", accountId, bucketVO.getName());
+            bucketVO.setAccessKey(iamCredential.getAccessKeyId());
+            bucketVO.setSecretKey(iamCredential.getSecretAccessKey());
+            _bucketDao.update(bucketVO.getId(), bucketVO);
+        }
+    }
+
+    /**
+     * Check whether the given access key id is still listed in IAM for the 
user.
+     */
+    private boolean iamAccessKeyExists(AmazonIdentityManagement iamClient, 
String userName, String accessKeyId) {
+        try {
+            for (AccessKeyMetadata metadata :
+                    iamClient.listAccessKeys(new ListAccessKeysRequest()
+                            .withUserName(userName)).getAccessKeyMetadata()) {
+                if (accessKeyId.equals(metadata.getAccessKeyId())) {
+                    return true;
+                }
+            }
+        } catch (AmazonClientException e) {
+            logger.warn("Failed to list IAM access keys for user {}: {}", 
userName, e.getMessage());
+        }
+        return false;
+    }
+
+    /**
+     * Delete access keys for the user other than the (optionally) preserved
+     * key id. Used to clean up unmanaged leftover keys before creating a
+     * replacement so repeated calls do not hit IAM access-key limits.
+     */
+    private void deleteUnmanagedAccessKeys(AmazonIdentityManagement iamClient, 
String userName, String preserveAccessKeyId) {
+        try {
+            for (AccessKeyMetadata metadata :
+                    iamClient.listAccessKeys(new ListAccessKeysRequest()
+                            .withUserName(userName)).getAccessKeyMetadata()) {
+                String keyId = metadata.getAccessKeyId();
+                if (preserveAccessKeyId != null && 
preserveAccessKeyId.equals(keyId)) {
+                    continue;
+                }
+                DeleteAccessKeyRequest deleteReq =
+                        new DeleteAccessKeyRequest()
+                                .withUserName(userName)
+                                .withAccessKeyId(keyId);
+                logger.info("Deleting un-managed IAM access key {} for user 
{}", keyId, userName);
+                iamClient.deleteAccessKey(deleteReq);
+            }
+        } catch (AmazonClientException e) {
+            logger.warn("Failed to clean up IAM access keys for user {}: {}", 
userName, e.getMessage());
+        }
+    }
+
+    @Override
+    public Bucket createBucket(Bucket bucket, boolean objectLock) {
+        String bucketName = bucket.getName();
+        long storeId = bucket.getObjectStoreId();
+        long accountId = bucket.getAccountId();
+
+        // Use the store's admin credentials to create the bucket
+        AmazonS3 s3client = getS3ClientByStoreId(storeId);
+
+        // Check if the bucket already exists
+        try {
+            if (s3client.doesBucketExistV2(bucketName)) {
+                throw new CloudRuntimeException("Bucket already exists with 
name " + bucketName);
+            }
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+
+        // Create the bucket
+        try {
+            CreateBucketRequest request = new CreateBucketRequest(bucketName);
+            if (objectLock) {
+                request.setObjectLockEnabledForBucket(true);
+            }
+            s3client.createBucket(request);
+        } catch (AmazonClientException e) {
+            logger.error("Create bucket failed", e);
+            throw new CloudRuntimeException(e);
+        }
+
+        // Update the bucket record with the account's IAM credentials
+        Map<String, String> accountDetails = 
_accountDetailsDao.findDetails(accountId);
+        String accessKey = 
accountDetails.get(SeaweedFSObjectStoreUtil.keyAccessKey(storeId));
+        String secretKey = 
accountDetails.get(SeaweedFSObjectStoreUtil.keySecretKey(storeId));
+        if (accessKey == null || secretKey == null) {
+            logger.warn("No IAM credentials found for account {}. Bucket will 
be created without per-account credentials.", accountId);
+        }
+
+        ObjectStoreVO store = _storeDao.findById(storeId);
+        String s3Url = getS3Url(storeId);
+        BucketVO bucketVO = _bucketDao.findById(bucket.getId());
+        bucketVO.setAccessKey(accessKey);
+        bucketVO.setSecretKey(secretKey);
+        bucketVO.setBucketURL(s3Url + "/" + bucketName);
+        _bucketDao.update(bucket.getId(), bucketVO);
+        return bucket;
+    }
+
+    @Override
+    public List<Bucket> listBuckets(long storeId) {
+        AmazonS3 s3client = getS3ClientByStoreId(storeId);
+        List<Bucket> bucketsList = new ArrayList<>();
+        try {
+            List<com.amazonaws.services.s3.model.Bucket> s3Buckets = 
s3client.listBuckets();
+            for (com.amazonaws.services.s3.model.Bucket s3Bucket : s3Buckets) {
+                Bucket bucket = new BucketObject();
+                bucket.setName(s3Bucket.getName());
+                bucketsList.add(bucket);
+            }
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+        return bucketsList;
+    }
+
+    @Override
+    public boolean deleteBucket(BucketTO bucket, long storeId) {
+        AmazonS3 s3client = getS3ClientByStoreId(storeId);
+        try {
+            if (! s3client.doesBucketExistV2(bucket.getName())) {
+                throw new CloudRuntimeException("Bucket doesn't exist: " + 
bucket.getName());
+            }
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+        try {
+            s3client.deleteBucket(bucket.getName());
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+        return true;
+    }
+
+    @Override
+    public AccessControlList getBucketAcl(BucketTO bucket, long storeId) {
+        AmazonS3 s3client = getS3ClientByStoreId(storeId);
+        try {
+            return s3client.getBucketAcl(bucket.getName());
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+    }
+
+    @Override
+    public void setBucketAcl(BucketTO bucket, AccessControlList acl, long 
storeId) {
+        AmazonS3 s3client = getS3ClientByStoreId(storeId);
+        try {
+            s3client.setBucketAcl(bucket.getName(), acl);
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+    }
+
+    @Override
+    public void setBucketPolicy(BucketTO bucket, String policy, long storeId) {
+        if ("private".equalsIgnoreCase(policy)) {
+            deleteBucketPolicy(bucket, storeId);
+            return;
+        }
+
+        StringBuilder sb = new StringBuilder();
+        sb.append("{\n");
+        sb.append("  \"Version\": \"2012-10-17\",\n");
+        sb.append("  \"Statement\": [\n");
+        sb.append("    {\n");
+        sb.append("      \"Sid\": \"PublicReadForObjects\",\n");
+        sb.append("      \"Effect\": \"Allow\",\n");
+        sb.append("      \"Principal\": \"*\",\n");
+        sb.append("      \"Action\": \"s3:GetObject\",\n");
+        sb.append("      \"Resource\": \"arn:aws:s3:::%s/*\"\n");
+        sb.append("    }\n");
+        sb.append("  ]\n");
+        sb.append("}\n");
+
+        String jsonPolicy = String.format(sb.toString(), bucket.getName());
+        AmazonS3 s3client = getS3ClientByStoreId(storeId);
+        try {
+            s3client.setBucketPolicy(bucket.getName(), jsonPolicy);
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+    }
+
+    @Override
+    public BucketPolicy getBucketPolicy(BucketTO bucket, long storeId) {
+        AmazonS3 s3client = getS3ClientByStoreId(storeId);
+        try {
+            return s3client.getBucketPolicy(new 
GetBucketPolicyRequest(bucket.getName()));
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+    }
+
+    @Override
+    public void deleteBucketPolicy(BucketTO bucket, long storeId) {
+        AmazonS3 s3client = getS3ClientByStoreId(storeId);
+        try {
+            s3client.deleteBucketPolicy(new 
DeleteBucketPolicyRequest(bucket.getName()));
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+    }
+
+    @Override
+    public boolean setBucketEncryption(BucketTO bucket, long storeId) {
+        AmazonS3 s3client = getS3ClientByStoreId(storeId);
+        try {
+            SetBucketEncryptionRequest eRequest = new 
SetBucketEncryptionRequest();
+            eRequest.setBucketName(bucket.getName());
+
+            ServerSideEncryptionByDefault sseByDefault = new 
ServerSideEncryptionByDefault();
+            sseByDefault.setSSEAlgorithm(SSEAlgorithm.AES256.toString());
+
+            ServerSideEncryptionRule sseRule = new ServerSideEncryptionRule();
+            sseRule.setApplyServerSideEncryptionByDefault(sseByDefault);
+
+            List<ServerSideEncryptionRule> sseRules = new ArrayList<>();
+            sseRules.add(sseRule);
+
+            ServerSideEncryptionConfiguration sseConf = new 
ServerSideEncryptionConfiguration();
+            sseConf.setRules(sseRules);
+
+            eRequest.setServerSideEncryptionConfiguration(sseConf);
+            s3client.setBucketEncryption(eRequest);
+            return true;
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+    }
+
+    @Override
+    public boolean deleteBucketEncryption(BucketTO bucket, long storeId) {
+        AmazonS3 s3client = getS3ClientByStoreId(storeId);
+        try {
+            s3client.deleteBucketEncryption(bucket.getName());
+            return true;
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+    }
+
+    @Override
+    public boolean setBucketVersioning(BucketTO bucket, long storeId) {
+        AmazonS3 s3client = getS3ClientByStoreId(storeId);
+        try {
+            BucketVersioningConfiguration vConf = new 
BucketVersioningConfiguration(BucketVersioningConfiguration.ENABLED);
+            s3client.setBucketVersioningConfiguration(
+                    new 
SetBucketVersioningConfigurationRequest(bucket.getName(), vConf));
+            return true;
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+    }
+
+    @Override
+    public boolean deleteBucketVersioning(BucketTO bucket, long storeId) {
+        AmazonS3 s3client = getS3ClientByStoreId(storeId);
+        try {
+            BucketVersioningConfiguration vConf = new 
BucketVersioningConfiguration(BucketVersioningConfiguration.SUSPENDED);
+            s3client.setBucketVersioningConfiguration(
+                    new 
SetBucketVersioningConfigurationRequest(bucket.getName(), vConf));
+            return true;
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+    }
+
+    /**
+     * Set the bucket quota via the SeaweedFS admin REST API.
+     *
+     * SeaweedFS enforces bucket quota server-side by setting a read-only flag
+     * when usage exceeds the configured limit. The quota is configured via the
+     * SeaweedFS S3 extension endpoint PUT /{bucket}?seaweedfs-quota,
+     * authenticated via standard S3 SigV4 and authorized via the
+     * s3:PutBucketQuota IAM permission.
+     *
+     * @param size the GiB size to set the quota to. 0 disables quota.
+     * @throws CloudRuntimeException if the S3 endpoint or credentials are 
missing or the request fails.
+     */
+    @Override
+    public void setBucketQuota(BucketTO bucket, long storeId, long size) {
+        String s3Url = getS3Url(storeId);
+        String accessKey = getAccessKey(storeId);
+        String secretKey = getSecretKey(storeId);
+        if (s3Url == null || s3Url.isEmpty() || accessKey == null || 
accessKey.isEmpty() || secretKey == null || secretKey.isEmpty()) {
+            throw new CloudRuntimeException("SeaweedFS S3 URL and credentials 
are required to set bucket quota. " +
+                    "Configure 's3Url', 'accesskey', and 'secretkey' in the 
object store details.");
+        }
+        SeaweedFSObjectStoreUtil.setBucketQuotaViaS3Extension(s3Url, 
accessKey, secretKey, bucket.getName(), size, getS3ExtensionHttpClient());
+    }
+
+    /**
+     * Returns the HTTP client used to send SeaweedFS S3 extension requests
+     * (e.g. PUT /{bucket}?seaweedfs-quota). Exposed as a protected seam so
+     * tests can inject a mock client and assert the signed request without
+     * touching the network.
+     */
+    protected java.net.http.HttpClient getS3ExtensionHttpClient() {
+        return SeaweedFSObjectStoreUtil.newS3ExtensionHttpClient();
+    }
+
+    @Override
+    public Map<String, Long> getAllBucketsUsage(long storeId) {
+        Map<String, Long> bucketUsage = new HashMap<>();
+        List<BucketVO> bucketList = _bucketDao.listByObjectStoreId(storeId);
+        if (bucketList.isEmpty()) {
+            return bucketUsage;
+        }
+
+        // List objects per bucket via S3 (no admin API needed).
+        // SeaweedFS also publishes per-bucket Prometheus metrics and an SOSAPI
+        // capacity.xml response; operators who need scalable usage reporting
+        // should consume those instead of S3 list-based aggregation.
+        AmazonS3 s3client = getS3ClientByStoreId(storeId);
+        for (BucketVO bucket : bucketList) {
+            try {
+                long size = 0L;
+                com.amazonaws.services.s3.model.ListObjectsV2Result result;
+                String continuationToken = null;
+                do {
+                    com.amazonaws.services.s3.model.ListObjectsV2Request req =
+                            new 
com.amazonaws.services.s3.model.ListObjectsV2Request()
+                                    .withBucketName(bucket.getName())
+                                    .withMaxKeys(1000);
+                    if (continuationToken != null) {
+                        req.setContinuationToken(continuationToken);
+                    }
+                    result = s3client.listObjectsV2(req);
+                    for (com.amazonaws.services.s3.model.S3ObjectSummary 
summary : result.getObjectSummaries()) {
+                        size += summary.getSize();
+                    }
+                    continuationToken = result.getNextContinuationToken();
+                } while (result.isTruncated());
+                bucketUsage.put(bucket.getName(), size);
+            } catch (AmazonClientException e) {
+                // Omit the bucket rather than reporting 0 — returning 0 would
+                // cause BucketApiServiceImpl to overwrite the stored size with
+                // a false zero, erasing known usage on a transient failure.
+                logger.warn("Failed to get usage for bucket {} (omitting from 
result): {}", bucket.getName(), e.getMessage());
+            }
+        }
+        return bucketUsage;
+    }
+
+    // ---- Client builders ----
+
+    protected String getS3Url(long storeId) {
+        // Prefer the current store URL (ObjectStoreVO.url) over the persisted
+        // s3Url detail. initialize() persists a resolved s3Url detail, but if
+        // an administrator later updates the store URL via updateObjectStore,
+        // the detail becomes stale. Using the current store URL keeps bucket
+        // operations pointed at the live endpoint.
+        ObjectStoreVO store = _storeDao.findById(storeId);
+        if (store != null && store.getUrl() != null && ! 
store.getUrl().isEmpty()) {
+            return store.getUrl();

Review Comment:
   When `details` contains a distinct `s3Url` (which `initialize` explicitly 
supports and persists), `ObjectStoreVO.url` is always non-empty and this branch 
returns it, so the configured S3 endpoint is ignored for bucket operations and 
quota requests. Either read the persisted `s3Url` first or make the resolved S3 
endpoint the stored object-store URL; currently the separate endpoint option is 
not functional.



##########
plugins/storage/object/seaweedfs/src/test/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImplTest.java:
##########
@@ -0,0 +1,654 @@
+// 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.
+// SPDX-License-Identifier: Apache-2.0
+package org.apache.cloudstack.storage.datastore.driver;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Flow;
+
+import java.io.ByteArrayOutputStream;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.cloudstack.storage.datastore.db.ObjectStoreDao;
+import org.apache.cloudstack.storage.datastore.db.ObjectStoreDetailsDao;
+import org.apache.cloudstack.storage.datastore.db.ObjectStoreVO;
+import org.apache.cloudstack.storage.datastore.util.SeaweedFSObjectStoreUtil;
+import org.apache.cloudstack.storage.object.Bucket;
+
+import com.amazonaws.services.identitymanagement.AmazonIdentityManagement;
+import com.amazonaws.services.identitymanagement.model.AccessKey;
+import com.amazonaws.services.identitymanagement.model.AccessKeyMetadata;
+import com.amazonaws.services.identitymanagement.model.CreateAccessKeyRequest;
+import com.amazonaws.services.identitymanagement.model.CreateAccessKeyResult;
+import com.amazonaws.services.identitymanagement.model.CreateUserRequest;
+import com.amazonaws.services.identitymanagement.model.DeleteAccessKeyRequest;
+import 
com.amazonaws.services.identitymanagement.model.EntityAlreadyExistsException;
+import com.amazonaws.services.identitymanagement.model.ListAccessKeysRequest;
+import com.amazonaws.services.identitymanagement.model.ListAccessKeysResult;
+import com.amazonaws.services.identitymanagement.model.PutUserPolicyRequest;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.model.BucketVersioningConfiguration;
+import com.amazonaws.services.s3.model.CreateBucketRequest;
+import com.amazonaws.services.s3.model.ListObjectsV2Request;
+import com.amazonaws.services.s3.model.ListObjectsV2Result;
+import com.amazonaws.services.s3.model.S3ObjectSummary;
+import com.amazonaws.services.s3.model.SetBucketVersioningConfigurationRequest;
+import com.cloud.agent.api.to.BucketTO;
+import com.cloud.storage.BucketVO;
+import com.cloud.storage.dao.BucketDao;
+import com.cloud.user.AccountDetailsDao;
+import com.cloud.user.AccountVO;
+import com.cloud.user.dao.AccountDao;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.Silent.class)
+public class SeaweedFSObjectStoreDriverImplTest {
+
+    @Spy
+    SeaweedFSObjectStoreDriverImpl driver = new 
SeaweedFSObjectStoreDriverImpl();
+
+    @Mock
+    AmazonS3 s3Client;
+    @Mock
+    AmazonIdentityManagement iamClient;
+    @Mock
+    ObjectStoreDao objectStoreDao;
+    @Mock
+    ObjectStoreVO objectStoreVO;
+    @Mock
+    ObjectStoreDetailsDao objectStoreDetailsDao;
+    @Mock
+    AccountDao accountDao;
+    @Mock
+    BucketDao bucketDao;
+    @Mock
+    AccountDetailsDao accountDetailsDao;
+    @Mock
+    AccountVO account;
+
+    BucketVO bucketVo;
+    Map<String, String> storeDetailsMap;
+    Map<String, String> accountDetailsMap;
+
+    static long TEST_STORE_ID = 1010L;
+    static long TEST_ACCOUNT_ID = 2010L;
+    static long TEST_DOMAIN_ID = 3010L;
+    static String TEST_ACCESS_KEY = "test_access_key";
+    static String TEST_SECRET_KEY = "test_secret_key";
+    static String TEST_BUCKET_NAME = "testbucketname";
+    static String TEST_S3_URL = "http://s3-endpoint";;
+    static String TEST_IAM_URL = "http://iam-endpoint";;
+    static String TEST_AK = "user_access_key";
+    static String TEST_SK = "user_secret_key";
+    static String TEST_BUCKET_URL = TEST_S3_URL + "/" + TEST_BUCKET_NAME;
+    static String TEST_ACCOUNT_UUID = "account-uuid-1234";
+
+    private AutoCloseable closeable;
+
+    @Before
+    public void setUp() {
+        closeable = MockitoAnnotations.openMocks(this);
+        driver._storeDao = objectStoreDao;
+        driver._storeDetailsDao = objectStoreDetailsDao;
+        driver._accountDao = accountDao;
+        driver._bucketDao = bucketDao;
+        driver._accountDetailsDao = accountDetailsDao;
+
+        
lenient().when(objectStoreDao.findById(TEST_STORE_ID)).thenReturn(objectStoreVO);
+        lenient().when(objectStoreVO.getUrl()).thenReturn(TEST_S3_URL);
+
+        storeDetailsMap = new HashMap<>();
+        
storeDetailsMap.put(SeaweedFSObjectStoreUtil.STORE_DETAILS_KEY_ACCESS_KEY, 
TEST_ACCESS_KEY);
+        
storeDetailsMap.put(SeaweedFSObjectStoreUtil.STORE_DETAILS_KEY_SECRET_KEY, 
TEST_SECRET_KEY);
+        storeDetailsMap.put(SeaweedFSObjectStoreUtil.STORE_DETAILS_KEY_S3_URL, 
TEST_S3_URL);
+        
storeDetailsMap.put(SeaweedFSObjectStoreUtil.STORE_DETAILS_KEY_IAM_URL, 
TEST_IAM_URL);
+        
lenient().when(objectStoreDetailsDao.getDetails(TEST_STORE_ID)).thenReturn(storeDetailsMap);
+
+        accountDetailsMap = new HashMap<>();
+        
accountDetailsMap.put(SeaweedFSObjectStoreUtil.keyAccessKey(TEST_STORE_ID), 
TEST_AK);
+        
accountDetailsMap.put(SeaweedFSObjectStoreUtil.keySecretKey(TEST_STORE_ID), 
TEST_SK);
+        
lenient().when(accountDetailsDao.findDetails(TEST_ACCOUNT_ID)).thenReturn(accountDetailsMap);
+
+        bucketVo = new BucketVO(TEST_ACCOUNT_ID, TEST_DOMAIN_ID, 
TEST_STORE_ID, TEST_BUCKET_NAME, null, false, false, false, null);
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        closeable.close();
+    }
+
+    @Test
+    public void testGetStoreTO() {
+        assertNull(driver.getStoreTO(null));
+    }
+
+    @Test
+    public void testCreateBucket() throws Exception {
+        doReturn(s3Client).when(driver).getS3ClientByStoreId(TEST_STORE_ID);
+        when(s3Client.doesBucketExistV2(TEST_BUCKET_NAME)).thenReturn(false);
+        when(bucketDao.findById(anyLong())).thenReturn(bucketVo);
+
+        Bucket result = driver.createBucket(bucketVo, false);
+
+        assertEquals(TEST_BUCKET_NAME, result.getName());
+
+        ArgumentCaptor<BucketVO> captor = 
ArgumentCaptor.forClass(BucketVO.class);
+        verify(bucketDao, times(1)).update(any(), captor.capture());
+        BucketVO updated = captor.getValue();
+        assertEquals(TEST_AK, updated.getAccessKey());
+        assertEquals(TEST_SK, updated.getSecretKey());
+        assertEquals(TEST_BUCKET_URL, updated.getBucketURL());
+
+        verify(s3Client, 
times(1)).createBucket(any(CreateBucketRequest.class));
+    }
+
+    @Test
+    public void testCreateBucketAlreadyExists() throws Exception {
+        doReturn(s3Client).when(driver).getS3ClientByStoreId(TEST_STORE_ID);
+        when(s3Client.doesBucketExistV2(TEST_BUCKET_NAME)).thenReturn(true);
+
+        assertThrows(CloudRuntimeException.class, () -> 
driver.createBucket(bucketVo, false));
+        verify(s3Client, never()).createBucket(any(CreateBucketRequest.class));
+    }
+
+    @Test
+    public void testListBuckets() throws Exception {
+        doReturn(s3Client).when(driver).getS3ClientByStoreId(TEST_STORE_ID);
+        List<com.amazonaws.services.s3.model.Bucket> s3Buckets = new 
ArrayList<>();
+        s3Buckets.add(new com.amazonaws.services.s3.model.Bucket("bucket1"));
+        s3Buckets.add(new com.amazonaws.services.s3.model.Bucket("bucket2"));
+        when(s3Client.listBuckets()).thenReturn(s3Buckets);
+
+        List<Bucket> result = driver.listBuckets(TEST_STORE_ID);
+
+        assertEquals(2, result.size());
+        assertEquals("bucket1", result.get(0).getName());
+        assertEquals("bucket2", result.get(1).getName());
+    }
+
+    @Test
+    public void testDeleteBucket() throws Exception {
+        doReturn(s3Client).when(driver).getS3ClientByStoreId(TEST_STORE_ID);
+        BucketTO bucketTO = mock(BucketTO.class);
+        when(bucketTO.getName()).thenReturn(TEST_BUCKET_NAME);
+        when(s3Client.doesBucketExistV2(TEST_BUCKET_NAME)).thenReturn(true);
+
+        assertTrue(driver.deleteBucket(bucketTO, TEST_STORE_ID));
+        verify(s3Client, times(1)).deleteBucket(TEST_BUCKET_NAME);
+    }
+
+    @Test
+    public void testDeleteBucketNotFound() throws Exception {
+        doReturn(s3Client).when(driver).getS3ClientByStoreId(TEST_STORE_ID);
+        BucketTO bucketTO = mock(BucketTO.class);
+        when(bucketTO.getName()).thenReturn(TEST_BUCKET_NAME);
+        when(s3Client.doesBucketExistV2(TEST_BUCKET_NAME)).thenReturn(false);
+
+        assertThrows(CloudRuntimeException.class, () -> 
driver.deleteBucket(bucketTO, TEST_STORE_ID));
+    }
+
+    @Test
+    public void testSetBucketVersioning() throws Exception {
+        doReturn(s3Client).when(driver).getS3ClientByStoreId(TEST_STORE_ID);
+        BucketTO bucketTO = mock(BucketTO.class);
+        when(bucketTO.getName()).thenReturn(TEST_BUCKET_NAME);
+
+        assertTrue(driver.setBucketVersioning(bucketTO, TEST_STORE_ID));
+        verify(s3Client, 
times(1)).setBucketVersioningConfiguration(any(SetBucketVersioningConfigurationRequest.class));
+    }
+
+    @Test
+    public void testDeleteBucketVersioning() throws Exception {
+        doReturn(s3Client).when(driver).getS3ClientByStoreId(TEST_STORE_ID);
+        BucketTO bucketTO = mock(BucketTO.class);
+        when(bucketTO.getName()).thenReturn(TEST_BUCKET_NAME);
+
+        assertTrue(driver.deleteBucketVersioning(bucketTO, TEST_STORE_ID));
+        ArgumentCaptor<SetBucketVersioningConfigurationRequest> captor =
+                
ArgumentCaptor.forClass(SetBucketVersioningConfigurationRequest.class);
+        verify(s3Client, 
times(1)).setBucketVersioningConfiguration(captor.capture());
+        assertEquals(BucketVersioningConfiguration.SUSPENDED, 
captor.getValue().getVersioningConfiguration().getStatus());
+    }
+
+    @Test
+    public void testSetBucketQuotaZero() throws Exception {
+        BucketTO bucketTO = mock(BucketTO.class);
+        when(bucketTO.getName()).thenReturn(TEST_BUCKET_NAME);
+        doReturn(TEST_S3_URL).when(driver).getS3Url(TEST_STORE_ID);
+        doReturn("access-key").when(driver).getAccessKey(TEST_STORE_ID);
+        doReturn("secret-key").when(driver).getSecretKey(TEST_STORE_ID);
+
+        HttpClient mockHttpClient = mock(HttpClient.class);
+        HttpResponse<String> mockResponse = mock(HttpResponse.class);
+        when(mockResponse.statusCode()).thenReturn(200);
+        when(mockResponse.body()).thenReturn("");
+        when(mockHttpClient.send(ArgumentMatchers.<HttpRequest>any(),
+                
ArgumentMatchers.<HttpResponse.BodyHandler<String>>any())).thenReturn(mockResponse);
+        doReturn(mockHttpClient).when(driver).getS3ExtensionHttpClient();
+
+        driver.setBucketQuota(bucketTO, TEST_STORE_ID, 0);
+
+        ArgumentCaptor<HttpRequest> reqCaptor = 
ArgumentCaptor.forClass(HttpRequest.class);
+        verify(mockHttpClient, times(1)).send(reqCaptor.capture(),
+                ArgumentMatchers.<HttpResponse.BodyHandler<String>>any());
+        HttpRequest sent = reqCaptor.getValue();
+        assertEquals("PUT", sent.method());
+        assertEquals("/" + TEST_BUCKET_NAME, sent.uri().getPath());
+        assertTrue("query must carry the seaweedfs-quota subresource",
+                sent.uri().getQuery().contains("seaweedfs-quota"));
+        assertNotNull("request must be SigV4-signed", 
sent.headers().firstValue("Authorization"));
+        
assertEquals("{\"quota_size\":0,\"quota_unit\":\"B\",\"quota_enabled\":false}", 
extractBody(sent));
+    }
+
+    @Test
+    public void testSetBucketQuotaNonZero() throws Exception {
+        BucketTO bucketTO = mock(BucketTO.class);
+        when(bucketTO.getName()).thenReturn(TEST_BUCKET_NAME);
+        doReturn(TEST_S3_URL).when(driver).getS3Url(TEST_STORE_ID);
+        doReturn("access-key").when(driver).getAccessKey(TEST_STORE_ID);
+        doReturn("secret-key").when(driver).getSecretKey(TEST_STORE_ID);
+
+        HttpClient mockHttpClient = mock(HttpClient.class);
+        HttpResponse<String> mockResponse = mock(HttpResponse.class);
+        when(mockResponse.statusCode()).thenReturn(200);
+        when(mockResponse.body()).thenReturn("");
+        when(mockHttpClient.send(ArgumentMatchers.<HttpRequest>any(),
+                
ArgumentMatchers.<HttpResponse.BodyHandler<String>>any())).thenReturn(mockResponse);
+        doReturn(mockHttpClient).when(driver).getS3ExtensionHttpClient();
+
+        driver.setBucketQuota(bucketTO, TEST_STORE_ID, 10);
+
+        ArgumentCaptor<HttpRequest> reqCaptor = 
ArgumentCaptor.forClass(HttpRequest.class);
+        verify(mockHttpClient, times(1)).send(reqCaptor.capture(),
+                ArgumentMatchers.<HttpResponse.BodyHandler<String>>any());
+        HttpRequest sent = reqCaptor.getValue();
+        assertEquals("PUT", sent.method());
+        assertEquals("/" + TEST_BUCKET_NAME, sent.uri().getPath());
+        assertTrue(sent.uri().getQuery().contains("seaweedfs-quota"));
+        assertNotNull(sent.headers().firstValue("Authorization"));
+        
assertEquals("{\"quota_size\":10,\"quota_unit\":\"GB\",\"quota_enabled\":true}",
 extractBody(sent));
+    }
+
+    @Test
+    public void testSetBucketQuotaPropagatesFailure() throws Exception {
+        BucketTO bucketTO = mock(BucketTO.class);
+        when(bucketTO.getName()).thenReturn(TEST_BUCKET_NAME);
+        doReturn(TEST_S3_URL).when(driver).getS3Url(TEST_STORE_ID);
+        doReturn("access-key").when(driver).getAccessKey(TEST_STORE_ID);
+        doReturn("secret-key").when(driver).getSecretKey(TEST_STORE_ID);
+
+        HttpClient mockHttpClient = mock(HttpClient.class);
+        HttpResponse<String> mockResponse = mock(HttpResponse.class);
+        when(mockResponse.statusCode()).thenReturn(403);
+        when(mockResponse.body()).thenReturn("forbidden");
+        when(mockHttpClient.send(ArgumentMatchers.<HttpRequest>any(),
+                
ArgumentMatchers.<HttpResponse.BodyHandler<String>>any())).thenReturn(mockResponse);
+        doReturn(mockHttpClient).when(driver).getS3ExtensionHttpClient();
+
+        assertThrows(CloudRuntimeException.class, () -> 
driver.setBucketQuota(bucketTO, TEST_STORE_ID, 10));
+    }
+
+    @Test
+    public void testSetBucketQuotaRejects3xx() throws Exception {
+        BucketTO bucketTO = mock(BucketTO.class);
+        when(bucketTO.getName()).thenReturn(TEST_BUCKET_NAME);
+        doReturn(TEST_S3_URL).when(driver).getS3Url(TEST_STORE_ID);
+        doReturn("access-key").when(driver).getAccessKey(TEST_STORE_ID);
+        doReturn("secret-key").when(driver).getSecretKey(TEST_STORE_ID);
+
+        HttpClient mockHttpClient = mock(HttpClient.class);
+        HttpResponse<String> mockResponse = mock(HttpResponse.class);
+        // 3xx must NOT be treated as success — the mutation was not applied
+        when(mockResponse.statusCode()).thenReturn(302);
+        when(mockResponse.body()).thenReturn("redirect");
+        when(mockHttpClient.send(ArgumentMatchers.<HttpRequest>any(),
+                
ArgumentMatchers.<HttpResponse.BodyHandler<String>>any())).thenReturn(mockResponse);
+        doReturn(mockHttpClient).when(driver).getS3ExtensionHttpClient();
+
+        assertThrows(CloudRuntimeException.class, () -> 
driver.setBucketQuota(bucketTO, TEST_STORE_ID, 10));
+    }
+
+    /**
+     * Deterministic SigV4 signature-verification test.
+     *
+     * Signs the same request through the AWS SDK v1 AWSS3V4Signer (the same
+     * signer the production code uses) and asserts that the Authorization
+     * header, signed headers, x-amz-content-sha256, and x-amz-date produced
+     * by the driver's request match. This catches signing regressions (e.g.
+     * the query parameter not being in the canonical query string) that a
+     * mere "header exists" check would miss.
+     */
+    @Test
+    public void testSetBucketQuotaSigV4SignatureVerification() throws 
Exception {
+        String accessKey = "AKIAIOSFODNN7EXAMPLE";
+        String secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
+        String bucketName = "quota-sig-test";
+        String s3Url = "http://s3.example.com:8333";;
+        long quotaGiB = 5;
+
+        BucketTO bucketTO = mock(BucketTO.class);
+        when(bucketTO.getName()).thenReturn(bucketName);
+        doReturn(s3Url).when(driver).getS3Url(TEST_STORE_ID);
+        doReturn(accessKey).when(driver).getAccessKey(TEST_STORE_ID);
+        doReturn(secretKey).when(driver).getSecretKey(TEST_STORE_ID);
+
+        HttpClient mockHttpClient = mock(HttpClient.class);
+        HttpResponse<String> mockResponse = mock(HttpResponse.class);
+        when(mockResponse.statusCode()).thenReturn(200);
+        when(mockResponse.body()).thenReturn("");
+        when(mockHttpClient.send(ArgumentMatchers.<HttpRequest>any(),
+                
ArgumentMatchers.<HttpResponse.BodyHandler<String>>any())).thenReturn(mockResponse);
+        doReturn(mockHttpClient).when(driver).getS3ExtensionHttpClient();
+
+        driver.setBucketQuota(bucketTO, TEST_STORE_ID, quotaGiB);
+
+        ArgumentCaptor<HttpRequest> reqCaptor = 
ArgumentCaptor.forClass(HttpRequest.class);
+        verify(mockHttpClient, times(1)).send(reqCaptor.capture(),
+                ArgumentMatchers.<HttpResponse.BodyHandler<String>>any());
+        HttpRequest sent = reqCaptor.getValue();
+
+        // Build the expected signed request the same way the production code 
does
+        String expectedBody = 
String.format("{\"quota_size\":%d,\"quota_unit\":\"GB\",\"quota_enabled\":true}",
 quotaGiB);
+        byte[] bodyBytes = expectedBody.getBytes(StandardCharsets.UTF_8);
+
+        com.amazonaws.DefaultRequest<?> expectedRequest = new 
com.amazonaws.DefaultRequest<>("s3");
+        expectedRequest.setEndpoint(java.net.URI.create(s3Url));
+        expectedRequest.setHttpMethod(com.amazonaws.http.HttpMethodName.PUT);
+        expectedRequest.setResourcePath("/" + bucketName);
+        expectedRequest.addParameter("seaweedfs-quota", "");
+        expectedRequest.setContent(new 
java.io.ByteArrayInputStream(bodyBytes));
+        expectedRequest.getHeaders().put("Content-Length", 
String.valueOf(bodyBytes.length));
+        expectedRequest.getHeaders().put("Content-Type", "application/json");
+
+        com.amazonaws.auth.AWSCredentials credentials = new 
com.amazonaws.auth.BasicAWSCredentials(accessKey, secretKey);
+        com.amazonaws.services.s3.internal.AWSS3V4Signer signer = new 
com.amazonaws.services.s3.internal.AWSS3V4Signer();
+        signer.setServiceName("s3");
+        signer.setRegionName("us-east-1");
+        signer.sign(expectedRequest, credentials);

Review Comment:
   The "deterministic" reference is signed with a second `AWSS3V4Signer` call, 
which derives `x-amz-date` from the current clock. If production signing and 
this call straddle a second boundary, the exact Authorization, date, and 
signature assertions fail intermittently even when the implementation is 
correct. Fix the signing timestamp via an injectable clock/fixed signer input, 
or avoid exact time-dependent header comparison.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to