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


##########
server/src/main/java/com/cloud/storage/StorageManagerImpl.java:
##########
@@ -4812,6 +4813,57 @@ public ObjectStore updateObjectStore(Long id, 
UpdateObjectStoragePoolCmd cmd) {
         return objectStoreVO;
     }
 
+    /**
+     * Object store detail key holding an explicit S3 endpoint override. 
Providers
+     * that support it (SeaweedFS, Cloudian HyperStore) prefer it over the 
generic
+     * ObjectStoreVO.url.
+     */
+    private static final String OBJECT_STORE_DETAIL_S3_URL = "s3Url";
+
+    /**
+     * Rewrite the stored bucketURL of every bucket on an object store after 
the
+     * generic store URL changes.
+     *
+     * BucketVO.bucketURL is written only at bucket creation. BucketResponse
+     * exposes it and the object-store browser builds its S3 client from it, so
+     * without this the browser keeps targeting the old endpoint after a URL
+     * change while the management server uses the new one.
+     *
+     * Skipped when the pool has an explicit s3Url detail: providers that 
support
+     * that override (SeaweedFS, Cloudian HyperStore) keep using it regardless 
of
+     * ObjectStoreVO.url, so rewriting the bucket URLs off the generic URL 
would
+     * point the browser at an endpoint the driver never uses.
+     */
+    private void updateBucketUrls(Long storeId, String oldUrl, String newUrl) {
+        if (oldUrl == null || newUrl == null || oldUrl.equals(newUrl)) {
+            return;
+        }
+        Map<String, String> storeDetails = 
_objectStoreDetailsDao.getDetails(storeId);
+        if (storeDetails != null && 
StringUtils.isNotBlank(storeDetails.get(OBJECT_STORE_DETAIL_S3_URL))) {
+            logger.debug("Object store {} has an explicit s3Url; leaving 
stored bucket URLs unchanged", storeId);
+            return;
+        }
+        // Both URLs are operator-supplied and may carry a trailing slash. 
Strip
+        // them so the retained suffix (which starts with '/') is not appended 
to
+        // a base that already ends in one, which would rewrite every bucket 
URL
+        // to '...//bucket' and break browser access.
+        String oldBase = StringUtils.stripEnd(oldUrl, "/");
+        String newBase = StringUtils.stripEnd(newUrl, "/");
+        if (oldBase.equals(newBase)) {
+            return;
+        }
+        for (BucketVO bucket : _bucketDao.listByObjectStoreId(storeId)) {
+            String bucketUrl = bucket.getBucketURL();
+            if (bucketUrl == null || !bucketUrl.startsWith(oldBase)) {
+                continue;
+            }
+            String suffix = bucketUrl.substring(oldBase.length());
+            bucket.setBucketURL(newBase + (suffix.startsWith("/") ? suffix : 
"/" + suffix));
+            _bucketDao.update(bucket.getId(), bucket);

Review Comment:
   The return value of `_bucketDao.update` is ignored, so `updateObjectStore` 
can return success after changing the store endpoint while some bucket rows 
still contain the old URL. The management server will use the new endpoint but 
the browser will continue using those stale `bucketURL` values. Check the 
update result and fail/rollback the object-store URL change when a bucket 
rewrite cannot be persisted.



##########
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:
##########
@@ -0,0 +1,1072 @@
+/*
+ * 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.BucketCrossOriginConfiguration;
+import com.amazonaws.services.s3.model.CORSRule;
+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.SetBucketCrossOriginConfigurationRequest;
+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.db.GlobalLock;
+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";
+
+    /**
+     * DB-backed global lock name prefix for serializing IAM provisioning and
+     * policy refreshes per store+account. Uses {@link GlobalLock} so the
+     * critical section is serialized across management servers in a
+     * clustered deployment, not just within a single JVM.
+     */
+    private static final String IAM_LOCK_PREFIX = "seaweedfs.iam.";
+    private static final String BUCKET_NAME_LOCK_PREFIX = "seaweedfs.bucket.";
+
+    private static String getIamLockName(long storeId, long accountId) {
+        return IAM_LOCK_PREFIX + storeId + "." + accountId;
+    }
+
+    /**
+     * Acquire a DB-backed global lock for IAM operations on the given
+     * store+account. Returns a {@link GlobalLock} that the caller must
+     * {@link GlobalLock#unlock()} in a {@code finally} block, or {@code null}
+     * if the lock could not be acquired within the timeout.
+     *
+     * <p>Protected so tests can override with a no-op lock (the DB-backed
+     * {@link GlobalLock} requires a real transaction context).
+     */
+    protected GlobalLock acquireIamLock(long storeId, long accountId) {
+        GlobalLock lock = GlobalLock.getInternLock(getIamLockName(storeId, 
accountId));
+        if (!lock.lock(300)) {
+            logger.warn("Failed to acquire IAM lock for store {} account {}", 
storeId, accountId);
+            lock.releaseRef();
+            return null;
+        }
+        return lock;
+    }
+
+    private static String getBucketNameLockName(long storeId, String 
bucketName) {
+        return BUCKET_NAME_LOCK_PREFIX + storeId + "." + bucketName;
+    }
+
+    /**
+     * Acquire a DB-backed global lock covering a bucket <em>name</em> on a 
store,
+     * independent of the owning account.
+     *
+     * S3 bucket names are globally unique within a store and are reusable 
after
+     * deletion, while the IAM lock is scoped to (store, account). Without this
+     * lock, once a delete removes the remote bucket but before the old owner's
+     * IAM policy is refreshed, a different account can recreate the same name 
and
+     * the old owner's credentials would still grant that ARN. Both 
createBucket
+     * and deleteBucket take this lock so the two never interleave for a name.
+     *
+     * <p>Protected so tests can override with a no-op lock (the DB-backed
+     * {@link GlobalLock} requires a real transaction context).
+     *
+     * @return the held lock, which the caller must {@link GlobalLock#unlock()}
+     *         and {@link GlobalLock#releaseRef()}, or {@code null} on timeout
+     */
+    protected GlobalLock acquireBucketNameLock(long storeId, String 
bucketName) {
+        GlobalLock lock = 
GlobalLock.getInternLock(getBucketNameLockName(storeId, bucketName));
+        if (!lock.lock(300)) {
+            logger.warn("Failed to acquire bucket name lock for store {} 
bucket {}", storeId, bucketName);
+            lock.releaseRef();
+            return null;
+        }
+        return lock;
+    }
+
+    @Override
+    public DataStoreTO getStoreTO(DataStore store) {
+        return null;
+    }
+
+    /**
+     * Get the SeaweedFS IAM user name for the given CloudStack account and
+     * store. The store ID is included so that two CloudStack pools pointing
+     * at the same SeaweedFS IAM service do not collide on the same
+     * {@code acs-<uuid>} user and overwrite each other's policy and access
+     * keys.
+     */
+    protected String getUserNameForAccount(Account account, long storeId) {
+        return String.format("%s-%d-%s", ACS_PREFIX, storeId, 
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, storeId);
+        AmazonIdentityManagement iamClient = getIAMClient(storeId);
+
+        // Serialize per store+account across management servers so two
+        // concurrent bucket requests do not both rotate credentials and leave
+        // bucket rows with mismatched key pairs.
+        GlobalLock lock = acquireIamLock(storeId, accountId);
+        if (lock == null) {
+            return false;
+        }
+        try {
+
+        // 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 a scoped IAM policy that allows access only to this
+        // account's own buckets (the tenant boundary). Refreshed whenever
+        // buckets are created or deleted. Use the lock-free variant since
+        // createUser already holds the IAM lock.
+        updateAccountIAMPolicyLocked(iamClient, storeId, accountId, null);
+
+        // Reuse the stored access key only if both the access key id and the
+        // secret key are present and the key is still Active in IAM; otherwise
+        // create a replacement.
+        Map<String, String> details = 
_accountDetailsDao.findDetails(accountId);
+        String accessKeyDetailKey = 
SeaweedFSObjectStoreUtil.keyAccessKey(storeId);
+        String secretKeyDetailKey = 
SeaweedFSObjectStoreUtil.keySecretKey(storeId);
+        String storedAccessKeyId = details.get(accessKeyDetailKey);
+        String storedSecretKey = details.get(secretKeyDetailKey);
+        if (storedAccessKeyId != null && storedSecretKey != null
+                && iamAccessKeyExists(iamClient, userName, storedAccessKeyId)) 
{
+            logger.debug("Reusing existing IAM access key {} for user {}", 
storedAccessKeyId, userName);
+            updateAccountBucketCredentials(storeId, accountId, 
storedAccessKeyId, storedSecretKey);
+            return true;
+        }
+
+        // The stored key is missing, inactive, or no longer in IAM. Clean up
+        // ALL keys (including the inactive stored one) before creating a
+        // replacement so we do not accumulate keys and hit IAM limits.
+        deleteUnmanagedAccessKeys(iamClient, userName, null);
+
+        CreateAccessKeyResult result = iamClient.createAccessKey(
+                new CreateAccessKeyRequest().withUserName(userName));
+        AccessKey key = result.getAccessKey();
+
+        // Persist the credential pair in account details (namespaced by 
storeId)
+        // before updating BucketVO rows. The reuse path above reconciles 
bucket
+        // rows every time, so a later bucket update failure is repairable on
+        // retry. AccountDetailsDao.persist(accountId, map) is deliberately not
+        // used: it expunges every detail for the account and can clobber 
another
+        // store's namespaced credentials.
+        try {
+            persistAccountCredentialsOrRollback(accountId, accessKeyDetailKey, 
secretKeyDetailKey,
+                    storedAccessKeyId, storedSecretKey, key);
+        } catch (RuntimeException e) {
+            deleteAccessKeyAfterCredentialPersistenceFailure(iamClient, 
userName, key.getAccessKeyId(), e);
+            throw e;
+        }
+
+        updateAccountBucketCredentials(storeId, accountId, key);
+
+        logger.info("Created IAM credentials {} for user {}", 
key.getAccessKeyId(), userName);
+        return true;
+        } finally {
+            lock.unlock();
+            lock.releaseRef();
+        }
+    }
+
+    /**
+     * Persist a SeaweedFS IAM credential pair without using
+     * AccountDetailsDao.persist(accountId, map), and restore the previous 
pair if
+     * either single-key write fails so callers never observe a half-new pair.
+     */
+    private void persistAccountCredentialsOrRollback(long accountId, String 
accessKeyDetailKey, String secretKeyDetailKey,
+            String previousAccessKey, String previousSecretKey, AccessKey key) 
{
+        try {
+            _accountDetailsDao.addDetail(accountId, accessKeyDetailKey, 
key.getAccessKeyId(), false);
+            _accountDetailsDao.addDetail(accountId, secretKeyDetailKey, 
key.getSecretAccessKey(), false);
+        } catch (RuntimeException e) {
+            CloudRuntimeException wrapped = new CloudRuntimeException("Failed 
to persist SeaweedFS IAM credential pair for account " + accountId, e);
+            restoreAccountCredentialDetail(accountId, accessKeyDetailKey, 
previousAccessKey, wrapped);
+            restoreAccountCredentialDetail(accountId, secretKeyDetailKey, 
previousSecretKey, wrapped);
+            throw wrapped;
+        }
+    }
+
+    private void restoreAccountCredentialDetail(long accountId, String 
detailKey, String previousValue, RuntimeException cause) {
+        try {
+            if (previousValue == null) {
+                _accountDetailsDao.removeDetail(accountId, detailKey);
+            } else {
+                _accountDetailsDao.addDetail(accountId, detailKey, 
previousValue, false);
+            }
+        } catch (RuntimeException rollbackEx) {
+            logger.error("Failed to restore account detail {} for account {} 
after SeaweedFS credential persistence failed",
+                    detailKey, accountId, rollbackEx);
+            cause.addSuppressed(rollbackEx);
+        }
+    }
+
+    private void 
deleteAccessKeyAfterCredentialPersistenceFailure(AmazonIdentityManagement 
iamClient, String userName,
+            String accessKeyId, RuntimeException cause) {
+        try {
+            iamClient.deleteAccessKey(new DeleteAccessKeyRequest()
+                    .withUserName(userName)
+                    .withAccessKeyId(accessKeyId));
+        } catch (AmazonClientException cleanupEx) {
+            logger.error("Failed to delete IAM access key {} for user {} after 
account credential persistence failed",
+                    accessKeyId, userName, cleanupEx);
+            cause.addSuppressed(cleanupEx);
+        }
+    }
+
+    private void updateAccountBucketCredentials(long storeId, long accountId, 
AccessKey iamCredential) {
+        updateAccountBucketCredentials(storeId, accountId, 
iamCredential.getAccessKeyId(), iamCredential.getSecretAccessKey());
+    }
+
+    /**
+     * Update the IAM credentials on all BucketVO rows for this store/account 
so
+     * previously created buckets reflect the current key pair.
+     */
+    private void updateAccountBucketCredentials(long storeId, long accountId, 
String accessKeyId, String secretAccessKey) {
+        List<BucketVO> bucketList = 
_bucketDao.listByObjectStoreIdAndAccountId(storeId, accountId);
+        for (BucketVO bucketVO : bucketList) {
+            if (accessKeyId.equals(bucketVO.getAccessKey()) && 
secretAccessKey.equals(bucketVO.getSecretKey())) {
+                continue;
+            }
+            logger.info("Updating accountId={} bucket {} with new IAM 
credentials", accountId, bucketVO.getName());
+            bucketVO.setAccessKey(accessKeyId);
+            bucketVO.setSecretKey(secretAccessKey);
+            if (!_bucketDao.update(bucketVO.getId(), bucketVO)) {
+                throw new CloudRuntimeException("Failed to update IAM 
credentials on bucket " + bucketVO.getName());
+            }
+        }
+    }
+
+    /**
+     * Refresh the per-account IAM user policy so it grants S3 access only to
+     * the account's current buckets (optionally excluding one, e.g. a bucket
+     * being deleted). This is the tenant boundary: each account's IAM
+     * credentials can only operate on that account's own buckets.
+     *
+     * Acquires the per-store/account IAM lock. Callers that already hold the
+     * lock (e.g. createUser, createBucket post-create) should call
+     * {@link #updateAccountIAMPolicyLocked} instead to avoid re-entrant lock
+     * acquisition warnings from GlobalLock.
+     *
+     * @param iamClient the IAM client
+     * @param storeId the object store
+     * @param accountId the CloudStack account
+     * @param excludeBucket a bucket name to omit (e.g. a bucket being 
deleted),
+     *                      or null to include all of the account's buckets
+     */
+    protected void updateAccountIAMPolicy(AmazonIdentityManagement iamClient, 
long storeId, long accountId, String excludeBucket) {
+        GlobalLock lock = acquireIamLock(storeId, accountId);
+        if (lock == null) {
+            throw new CloudRuntimeException("Failed to acquire IAM lock for 
store " + storeId + " account " + accountId);
+        }
+        try {
+            updateAccountIAMPolicyLocked(iamClient, storeId, accountId, 
excludeBucket);
+        } finally {
+            lock.unlock();
+            lock.releaseRef();
+        }
+    }
+
+    /**
+     * Lock-free variant of {@link #updateAccountIAMPolicy} for callers that
+     * already hold the per-store/account IAM lock. Performs the policy refresh
+     * without reacquiring the lock, avoiding the GlobalLock re-entrant
+     * acquisition warning.
+     */
+    protected void updateAccountIAMPolicyLocked(AmazonIdentityManagement 
iamClient, long storeId, long accountId, String excludeBucket) {
+        Account account = _accountDao.findById(accountId);
+        if (account == null) {
+            return;
+        }
+        String userName = getUserNameForAccount(account, storeId);
+        List<BucketVO> buckets = 
_bucketDao.listByObjectStoreIdAndAccountId(storeId, accountId);
+        List<String> bucketNames = new ArrayList<>();
+        for (BucketVO bvo : buckets) {
+            if (excludeBucket != null && excludeBucket.equals(bvo.getName())) {
+                continue;
+            }
+            // Skip buckets whose remote counterpart has been deleted but whose
+            // row is still present for resource accounting. Including them
+            // would re-grant a bucket name that is now free for another 
account
+            // to claim.
+            if (Bucket.State.Destroyed.equals(bvo.getState())) {
+                continue;
+            }
+            bucketNames.add(bvo.getName());
+        }
+        String policy = 
SeaweedFSObjectStoreUtil.buildAccountIAMPolicy(bucketNames);
+        iamClient.putUserPolicy(new PutUserPolicyRequest(userName,
+                SeaweedFSObjectStoreUtil.IAM_USER_POLICY_NAME, policy));
+    }
+
+    /**
+     * Check whether the given access key id is still listed and Active in IAM
+     * for the user. Listing failures are propagated rather than swallowed so
+     * a transient IAM outage does not send createUser into the replacement
+     * path (which would overwrite stored credentials and invalidate bucket
+     * records).
+     */
+    private boolean iamAccessKeyExists(AmazonIdentityManagement iamClient, 
String userName, String accessKeyId) {
+        for (AccessKeyMetadata metadata :
+                iamClient.listAccessKeys(new ListAccessKeysRequest()
+                        .withUserName(userName)).getAccessKeyMetadata()) {
+            if (accessKeyId.equals(metadata.getAccessKeyId())) {
+                return "Active".equalsIgnoreCase(metadata.getStatus());
+            }
+        }
+        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) {
+            // Propagate so the caller does not proceed to create a replacement
+            // key while stale unmanaged keys remain (which could hit IAM key
+            // limits or leave orphaned credentials).
+            throw new CloudRuntimeException("Failed to clean up IAM access 
keys for user " + userName, e);
+        }
+    }
+
+    @Override
+    public Bucket createBucket(Bucket bucket, boolean objectLock) {
+        String bucketName = bucket.getName();
+        long storeId = bucket.getObjectStoreId();
+
+        // Serialize against a concurrent deleteBucket of the same name by any
+        // account on this store. Bucket names are globally unique per store 
and
+        // reusable, so without this an account could claim a name while the
+        // previous owner's IAM policy still granted that ARN.
+        GlobalLock nameLock = acquireBucketNameLock(storeId, bucketName);
+        if (nameLock == null) {
+            throw new CloudRuntimeException("Failed to acquire bucket name 
lock for store " + storeId + " bucket " + bucketName);
+        }
+        try {
+            return createBucketLocked(bucket, objectLock);
+        } finally {
+            nameLock.unlock();
+            nameLock.releaseRef();
+        }
+    }
+
+    private Bucket createBucketLocked(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);
+        }
+
+        // Step 2: update the bucket record with the account's IAM credentials,
+        // configure CORS, and refresh the IAM policy. If any of these fail,
+        // clean up the remote bucket so a retry does not find it already
+        // existing — mirroring the Cloudian createBucket pattern.
+        //
+        // Hold the IAM lock for the account so a concurrent createUser key
+        // rotation does not change the account credentials between reading
+        // them and writing the BucketVO, which would leave the bucket with
+        // a stale key pair.
+        GlobalLock iamLock = acquireIamLock(storeId, accountId);
+        if (iamLock == null) {
+            // The S3 bucket has already been created. Clean it up so a
+            // retry does not find it already existing, then throw.
+            try {
+                s3client.deleteBucket(bucketName);
+            } catch (AmazonClientException cleanupEx) {
+                logger.error("Failed to clean up bucket {} after IAM lock 
timeout", bucketName, cleanupEx);
+            }
+            throw new CloudRuntimeException("Failed to acquire IAM lock for 
store " + storeId + " account " + accountId);
+        }
+        try {
+            // Configure permissive CORS so the CloudStack S3 bucket browser
+            // (which performs list/upload/delete from the browser) can 
function.
+            // SeaweedFS supports the standard PutBucketCors operation.
+            configureBucketCORS(s3client, bucketName);
+
+            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) {
+                throw new CloudRuntimeException("No IAM credentials found for 
account " + accountId
+                        + " on store " + storeId + ". Run createUser before 
creating a bucket.");
+            }
+
+            String s3Url = getS3Url(storeId);
+            BucketVO bucketVO = _bucketDao.findById(bucket.getId());
+            bucketVO.setAccessKey(accessKey);
+            bucketVO.setSecretKey(secretKey);
+            // Normalize the endpoint: s3Url is operator-supplied and may 
carry a
+            // trailing slash, which would persist a broken "...//bucket" URL
+            // that BucketResponse and the object store browser both use.
+            
bucketVO.setBucketURL(SeaweedFSObjectStoreUtil.stripTrailingSlashes(s3Url) + 
"/" + bucketName);
+            _bucketDao.update(bucket.getId(), bucketVO);

Review Comment:
   This ignores the DAO's boolean result. If the row update affects no rows, 
the method still refreshes IAM and returns `bucketVO`; `BucketApiServiceImpl` 
can then promote a stale re-read and report success without persisting the 
credentials or bucket URL, leaving the bucket unusable through the returned 
credentials. Treat a `false` result as a failure so the existing cleanup path 
removes the remote bucket and revokes the policy grant.
   
   This issue also appears on line 897 of the same file.



-- 
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