Conversation
Add a new object storage provider plugin for SeaweedFS, alongside the
existing MinIO, Ceph RGW, and Cloudian HyperStore providers.
SeaweedFS exposes an S3-compatible API and an AWS IAM-compatible API, so
this provider uses the AWS S3 and IAM Java SDKs — the same approach as
the Cloudian HyperStore provider. No proprietary admin client is needed.
Key features:
- Bucket CRUD, policy, versioning, encryption, ACLs via AmazonS3 SDK
- Per-account IAM user provisioning via AmazonIdentityManagement SDK
- Per-bucket quota via the SeaweedFS S3 ?seaweedfs-quota extension
(PUT /{bucket}?seaweedfs-quota), authenticated via SigV4 and authorized
via the s3:PutBucketQuota IAM permission. This requires SeaweedFS PR
apache#11279.
- Usage reporting via S3 ListObjectsV2 (MVP; Prometheus or SOSAPI
capacity.xml recommended for production scale)
The service credential (accesskey/secretkey on the object store) is
granted only s3:PutBucketQuota and s3:GetBucketQuota via an IAM policy,
so it cannot delete buckets, manage users, or change cluster topology.
The plugin follows the Cloudian HyperStore pattern almost line for line:
same store-details keys (s3Url, iamUrl, accesskey, secretkey), same
IAM-user-with-restricted-policy pattern, same Spring wiring.
SeaweedFS registers its embedded IAM API at POST / on the same S3 endpoint (UnifiedPostHandler in s3api_server.go), not under /iam. The AWS IAM SDK uses the Query protocol and POSTs to the endpoint root, so defaulting iamUrl to <s3Url>/iam would send IAM operations to an unregistered path. Default to s3Url instead; a separate iamUrl is only needed for deployments running a standalone weed iam server. Found by Greptile review on PR apache#11279.
…licy constant Two issues found by CodeRabbit review on PR apache#11279: 1. S3Signer implements legacy S3 Signature Version 2, not SigV4. SeaweedFS expects SigV4. Replace with AWSS3V4Signer which implements AWS Signature Version 4. The seaweedfs-quota query parameter is included in the signed canonical query string. 2. SERVICE_CREDENTIAL_POLICY was a dead constant (never referenced) that claimed the service credential is scoped to only s3:PutBucketQuota/s3:GetBucketQuota. This contradicts the actual implementation, which uses the service credential (admin) for all driver operations: bucket CRUD, IAM user provisioning, and quota. Remove the dead constant and document the actual credential model.
|
Congratulations on your first Pull Request and welcome to the Apache CloudStack community! If you have any issues or are unsure about any anything please check our Contribution Guide (https://github.com/apache/cloudstack/blob/main/CONTRIBUTING.md)
|
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved packaging, lifecycle, IAM isolation, credential handling, and quota-signing issues remain.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds SeaweedFS as a CloudStack object-storage provider using S3/IAM APIs and SeaweedFS quota support.
Changes:
- Implements provider lifecycle, bucket, IAM, quota, and usage operations.
- Adds Maven and Spring module registration.
- Adds provider and driver tests.
File summaries
| File | Description |
|---|---|
plugins/storage/object/seaweedfs/src/test/java/org/apache/cloudstack/storage/datastore/provider/SeaweedFSObjectStoreProviderImplTest.java |
Tests provider registration. |
plugins/storage/object/seaweedfs/src/test/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImplTest.java |
Tests driver behavior and quota handling. |
plugins/storage/object/seaweedfs/src/main/resources/META-INF/cloudstack/storage-object-seaweedfs/spring-storage-object-seaweedfs-context.xml |
Registers Spring provider wiring. |
plugins/storage/object/seaweedfs/src/main/resources/META-INF/cloudstack/storage-object-seaweedfs/module.properties |
Defines module metadata. |
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java |
Builds clients and quota requests. |
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/provider/SeaweedFSObjectStoreProviderImpl.java |
Registers the provider. |
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/SeaweedFSObjectStoreLifeCycleImpl.java |
Handles pool lifecycle and validation. |
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java |
Implements storage and IAM operations. |
plugins/storage/object/seaweedfs/pom.xml |
Defines module dependencies. |
plugins/pom.xml |
Registers the SeaweedFS module. |
Review details
Suppressed comments (4)
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:430
- On any transient per-bucket S3 failure, this inserts
0.BucketApiServiceImpltreats returned values as authoritative and persists bucket and object-store usage, so a temporary timeout resets usage to zero and under-reports capacity. Propagate the failure for the whole store (or skip the store update) instead of publishing zero.
} catch (AmazonClientException e) {
logger.warn("Failed to get usage for bucket {}: {}", bucket.getName(), e.getMessage());
bucketUsage.put(bucket.getName(), 0L);
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:271
HttpClient.newHttpClient()and this request have no connect or request timeout. A stalled SeaweedFS endpoint can block the synchronous bucket create/update API worker indefinitely. Use a shared client with configured connect and request timeouts, preferably using the provider's existing timeout configuration.
java.net.http.HttpClient client = java.net.http.HttpClient.newHttpClient();
java.net.http.HttpResponse<String> response = client.send(reqBuilder.build(),
java.net.http.HttpResponse.BodyHandlers.ofString());
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:261
- The request adds
Content-Lengthto the SDK headers at line 243, then copies every header intojava.net.http.HttpRequest.Builder. Java's HttpClient rejectsContent-Lengthas a restricted header, so this loop can throw before the request is sent. Filter transport-managed headers (at leastContent-Length, andHostif present) and let HttpClient generate them while signing the same body.
for (java.util.Map.Entry<String, String> entry : request.getHeaders().entrySet()) {
if (entry.getKey() != null && entry.getValue() != null) {
reqBuilder.header(entry.getKey(), entry.getValue());
}
plugins/storage/object/seaweedfs/src/test/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImplTest.java:280
- The common
setUpalready populatesstoreDetailsMapwith the S3 URL and both credentials, so this test never exercises the missing-configuration guard; it passes only because the real HTTP call fails. Clear or override the details map and assert the validation message before any network call.
// No S3 URL/credentials configured — should throw with a clear message
assertThrows(CloudRuntimeException.class, () -> driver.setBucketQuota(bucketTO, TEST_STORE_ID, 10));
- Files reviewed: 10/10 changed files
- Comments generated: 6
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| <module>storage/object/minio</module> | ||
| <module>storage/object/ceph</module> | ||
| <module>storage/object/cloudian</module> | ||
| <module>storage/object/seaweedfs</module> |
- ship provider in client packaging (add cloud-plugin-storage-object-seaweedfs to client/pom.xml), matching the other object-storage providers - copy the size param through initialize() so ObjectStoreHelper no longer NPEs on addObjectStoragePool - sign ?seaweedfs-quota as a canonical query parameter (split path/query, addParameter before signing) instead of leaving it unsigned in the URI - skip restricted HTTP headers (Content-Length/Host/...) when copying signed headers onto the java.net.http request - make the S3-extension HttpClient injectable so quota tests assert the signed path/headers/body without hitting the network - createUser now reuses a stored IAM access key when it still exists in IAM, only creating a replacement (after cleaning up unmanaged leftover keys) when the stored key is gone, preventing credential rotation and IAM access-key limits - rewrite quota tests to assert the signed request via a mock HttpClient; add createUser reuse and replacement tests
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved moderate and critical issues remain in quota handling, credential management, usage reporting, endpoint updates, and concurrency.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (10)
client/pom.xml:669
- The existing Add Object Storage UI hard-codes its provider list and has no
SeaweedFSentry (ui/src/views/infra/AddObjectStorage.vue:130), so adding this dependency still leaves the provider unavailable through the supported UI. Add the provider to the UI (the default URL fields already match this lifecycle) or explicitly limit the feature to API registration.
<dependency>
<groupId>org.apache.cloudstack</groupId>
<artifactId>cloud-plugin-storage-object-seaweedfs</artifactId>
<version>${project.version}</version>
</dependency>
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:520
initializealways persists a resolveds3Urlin the object-store details, so this branch will return that stale value afterupdateObjectStorechangesObjectStoreVO.url. The generic update path then callslistBuckets()against the old endpoint and can accept an unreachable new URL; subsequent bucket operations continue using the old endpoint. Synchronize the detail URL when the store URL changes or use the current store URL for this provider.
Map<String, String> storeDetails = _storeDetailsDao.getDetails(storeId);
String s3Url = storeDetails.get(SeaweedFSObjectStoreUtil.STORE_DETAILS_KEY_S3_URL);
if (s3Url == null || s3Url.isEmpty()) {
ObjectStoreVO store = _storeDao.findById(storeId);
s3Url = store.getUrl();
}
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:170
- When the stored key is missing, this path creates a replacement and persists it only in account details. Existing
BucketVOrows still contain the old pair;createBucketResponseexposes those row values, so previously created buckets continue to hand clients invalid credentials after rotation. Update all buckets for this store/account with the new key pair, as the Cloudian driver does.
// Persist the credentials in the account details
details.put(SeaweedFSObjectStoreUtil.KEY_ACCESS_KEY, key.getAccessKeyId());
details.put(SeaweedFSObjectStoreUtil.KEY_SECRET_KEY, key.getSecretAccessKey());
_accountDetailsDao.persist(accountId, details);
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:500
ListObjectsV2reports only current versions. For a versioned bucket, noncurrent object versions are omitted even though they still consume storage, so usage andBucketVO.sizeare undercounted after overwrites or deletes. Use a version-aware listing or a SeaweedFS usage endpoint when versioning is enabled.
for (com.amazonaws.services.s3.model.S3ObjectSummary summary : result.getObjectSummaries()) {
size += summary.getSize();
}
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:458
- CloudStack's createBucket API makes quota required and calls
setQuotaeven for a value of 0, so a SeaweedFS deployment without this extension receives a 404 here and the surrounding create flow rolls the bucket back. That contradicts the description that bucket CRUD still works without the extension; either make zero quota a no-op/handle unsupported servers, or make the extension an explicit hard prerequisite for bucket creation.
SeaweedFSObjectStoreUtil.setBucketQuotaViaS3Extension(s3Url, accessKey, secretKey, bucket.getName(), size, getS3ExtensionHttpClient());
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:164
- Two concurrent
createUsercalls for the same account can both observe no stored key and no IAM keys, then each create and persist a different key. The last persistence wins, leaving the other key unmanaged; later calls see the persisted key and never enterdeleteUnmanagedAccessKeys, so the orphan can accumulate and eventually hit IAM key limits. Serialize per-account provisioning or re-list and reconcile keys after creation.
CreateAccessKeyResult result = iamClient.createAccessKey(
new CreateAccessKeyRequest().withUserName(userName));
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:74
s3:*onResource: "*"is not a restricted per-account policy. The credentials persisted into each account's bucket record/response can read and write every bucket and, with SeaweedFS's news3:PutBucketQuotaaction, change any tenant's quota; the deny only covers bucket creation/deletion. Scope account credentials to that account's buckets/object actions and keep quota/admin mutations on a separate service credential.
" \"s3:*\"\n" +
" ],\n" +
" \"Resource\": \"*\"\n" +
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:328
- Only status codes
>= 400are treated as failures, so a3xxresponse is reported as a successful quota update even thoughHttpClient.newHttpClient()does not follow redirects by default and the mutation was not applied. Accept only the 2xx success range here.
if (response.statusCode() >= 400) {
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:58
AccountDetailsDaois account-scoped—its lookup and persistence APIs accept onlyaccountId—while this driver provisions credentials for a specificstoreId. If one account uses two SeaweedFS pools, the second provisioning overwrites these fixed keys; a later bucket creation in the first pool then reads and publishes the second pool's credentials. Namespace the keys by store ID or use store-scoped credential storage.
public static final String KEY_ACCESS_KEY = "swfs_AccessKey";
public static final String KEY_SECRET_KEY = "swfs_SecretKey";
plugins/storage/object/seaweedfs/src/test/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImplTest.java:345
setUp()always stubsgetDetails(TEST_STORE_ID)with a valid URL and credentials, so this test does not exercise the missing-configuration path. It instead creates a realHttpClientand attempts a network request, allowing the test to pass for the wrong reason. Clear or overridestoreDetailsMapbefore the assertion so the exception is caused by missing credentials.
public void testSetBucketQuotaNoS3ConfigThrows() {
BucketTO bucketTO = mock(BucketTO.class);
when(bucketTO.getName()).thenReturn(TEST_BUCKET_NAME);
// No S3 URL/credentials configured — should throw with a clear message
assertThrows(CloudRuntimeException.class, () -> driver.setBucketQuota(bucketTO, TEST_STORE_ID, 10));
- Files reviewed: 11/11 changed files
- Comments generated: 4
- Review effort level: Lite
AccountDetailsDao is account-scoped, so fixed key names like swfs_AccessKey meant a second SeaweedFS pool for the same account would overwrite the first pool's credentials. Replace the fixed constants with keyAccessKey(storeId)/keySecretKey(storeId) methods that namespace by store ID.
When createUser creates a replacement IAM access key, existing BucketVO rows still carried the old key pair, so previously created buckets kept handing clients invalid credentials. Add updateAccountBucketCredentials to update all bucket records for the store/account, mirroring the Cloudian HyperStore driver.
…etail initialize() persists a resolved s3Url in the object-store details, so getS3Url returned that stale value after updateObjectStore changed ObjectStoreVO.url. Bucket operations continued using the old endpoint. Prefer the current store URL and fall back to the detail only if it is missing.
Returning 0 for a failed S3 listing caused BucketApiServiceImpl to overwrite the stored BucketVO.size with a false zero, erasing known usage on a transient endpoint or permission failure. Omit the bucket from the result map instead so the caller retains the previous value.
HttpClient.newHttpClient() had no connect timeout and the HttpRequest had no per-request timeout, so a stalled or unreachable SeaweedFS endpoint could block the synchronous bucket create/update API indefinitely. Add a 10s connect timeout on the client and a 30s request timeout on each HttpRequest.
Only status codes >= 400 were treated as failures, so a 3xx response was reported as a successful quota update even though HttpClient does not follow redirects by default and the mutation was not applied. Accept only the 2xx range. Add a test asserting 3xx is rejected.
…list The Add Object Storage view hard-codes its provider list and had no SeaweedFS entry, so the provider was only available via API. Add it to the dropdown; the default URL/accessKey/secretKey fields already match this lifecycle.
…e details setUp() always stubs getDetails with a valid URL and credentials, so the test did not exercise the missing-configuration path — it created a real HttpClient and attempted a network request, passing for the wrong reason. Clear storeDetailsMap and null the store lookup so the exception comes from the missing-config check.
The quota tests only checked that an Authorization header exists; they did not verify the SigV4 canonical query, payload hash, or signed headers against a known signature. A signing mismatch would therefore pass the suite and make every quota operation fail against SeaweedFS. Add a test that independently signs the same request through AWSS3V4Signer and asserts the Authorization, x-amz-content-sha256, and x-amz-date headers match exactly.
bc00f66 to
e353f6f
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Ten unresolved moderate findings affect credential consistency, endpoint handling, tenant isolation, quota validation, and test determinism.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (8)
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:172
- Persisting the new key before updating BucketVOs creates a one-way inconsistency: if
updateAccountBucketCredentialsfails for any existing bucket, the key remains stored while some bucket rows still contain old credentials. The next invocation returns at lines 155-157 without retrying the repair, so those buckets can continue exposing invalid credentials. Make persistence and bucket updates transactional, or reconcile all bucket rows on the reuse path.
_accountDetailsDao.persist(accountId, details);
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:554
- When
iamUrlis omitted,initialize()stores it as the initials3Url, butupdateObjectStoreonly changesObjectStoreVO.url. After a URL update, this accessor keeps IAM provisioning on the old endpoint while S3 operations use the new one, so account or bucket creation can fail or modify the wrong SeaweedFS instance. Track whetheriamUrlwas explicit or update this detail with the URL change.
protected String getIAMUrl(long storeId) {
Map<String, String> storeDetails = _storeDetailsDao.getDetails(storeId);
return storeDetails.get(SeaweedFSObjectStoreUtil.STORE_DETAILS_KEY_IAM_URL);
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:207
iamAccessKeyExistsonly matches the key ID, so an IAM key whose status isInactiveis treated as usable. If an administrator disables the stored key,createUserreturns true and new bucket records continue using credentials that cannot authenticate. CheckAccessKeyMetadata.getStatus()and rotate/replace inactive keys before reusing them.
if (accessKeyId.equals(metadata.getAccessKeyId())) {
return true;
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:157
- The reuse check validates only the access-key ID. If the secret-key detail is missing or corrupted, this returns success and never repairs the credential pair, leaving newly created or existing bucket records unusable. Require both stored values before reusing the key; otherwise treat the pair as invalid and rotate it without preserving the unusable key.
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;
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:213
- This catch treats every
AmazonClientExceptionfromListAccessKeysas evidence that the stored key is absent. A timeout, authentication failure, or temporary IAM outage therefore sendscreateUserinto the replacement path, which can overwrite the persisted credentials, invalidate bucket records, or hit the IAM key limit. Propagate or retry lookup failures and rotate only after a successful listing that omits the stored ID.
} catch (AmazonClientException e) {
logger.warn("Failed to list IAM access keys for user {}: {}", userName, e.getMessage());
}
return false;
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:99
- These IAM credentials are persisted for the account and exposed as bucket credentials, so this policy is the tenant boundary. Allowing
s3:*onResource: "*"gives every account access to every bucket in the SeaweedFS pool (includingPutBucketQuota); denying only create/delete does not isolate accounts. Scope the policy to the account's bucket ARNs and update it as buckets are created or deleted, as the MinIO driver does.
" \"Action\": [\n" +
" \"s3:*\"\n" +
" ],\n" +
" \"Resource\": \"*\"\n" +
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:341
pathalways starts with/, soURI.resolve(path)replaces any path prefix ins3Url. For an endpoint behind a reverse proxy such ashttps://host/object-s3, the outgoing quota request is sent to/bucketinstead of/object-s3/bucket, so it reaches the wrong route (and can fail signature verification). Build the outgoing URI while preserving the endpoint's existing path.
java.net.URI fullUri = endpointUri.resolve(path);
if (! queryString.isEmpty()) {
fullUri = java.net.URI.create(fullUri.toString() + "?" + queryString);
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:251
- Negative quotas are treated as a disable request here, but
updateBucketQuotapersists the caller's negative value and computes resource accounting from it. Changing a 5-GiB bucket to -1 therefore frees 6 GiB and stores -1 even though the server quota was cleared. Reject negative values; only zero should disable.
if (sizeGiB <= 0) {
- Files reviewed: 12/12 changed files
- Comments generated: 2
- Review effort level: Lite
…cket The IAM policy refresh in deleteBucket ran before BucketApiServiceImpl removed the BucketVO row. A concurrent createUser or createBucket policy rebuild (which reads the bucket list from the DB) could re-add the deleted bucket ARN between the exclusion and the row removal, leaving a stale grant that could be exploited if the bucket name was reused. Remove the BucketVO row before the policy refresh so concurrent rebuilds do not see the stale row. BucketApiServiceImpl subsequent _bucketDao.remove is idempotent.
The quota-disable 404 tolerance treated every 404/405 as an absent quota extension. SeaweedFS also returns 404 with a NoSuchBucket error body when the bucket does not exist, so a missing remote bucket with quota 0 was reported as success and the database quota was updated, leaving CloudStack and S3 inconsistent. Exclude NoSuchBucket responses from the tolerance so only the extension-not-available case is swallowed.
…orting getAllBucketsUsage listed every object in every bucket via S3 ListObjectsV2, issuing one request per 1,000 objects, every hour under the global BucketUsage lock. Large deployments turned the management server scan into a sustained O(total objects) workload. When the operator configures a metricsUrl store detail pointing at the SeaweedFS Prometheus metrics endpoint, getAllBucketsUsage now scrapes /metrics and parses the seaweed_s3_bucket_size_bytes gauge in a single HTTP GET, returning all bucket sizes in O(buckets) time. The ListObjectsV2 scan is retained as a fallback for deployments without a metrics endpoint; if the scrape fails, the driver logs and falls back to listing.
Adds testGetAllBucketsUsageFromMetrics verifying the metrics path returns per-bucket sizes from a single scrape, filters unmanaged buckets, and does not call ListObjectsV2. Adds testGetAllBucketsUsageMetricsFailureFallsBackToList verifying that an HTTP 503 from the metrics endpoint falls back to the ListObjectsV2 scan.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved issues affect cleanup retryability, versioned usage accounting, SigV4 path signing, UI endpoint overrides, and lock warning noise.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (6)
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:429
- When a post-create step fails,
createBucketthrows beforeBucketApiServiceImplsetsbucketCreated=true, so the service will not perform its own cleanup. If this delete fails and the exception is only logged, the service removes the CloudStack row while the remote bucket remains orphaned, permanently consuming the name and storage with no retry path. Preserve retryable cleanup state or propagate enough state for the caller to retry/record the orphan.
s3client.deleteBucket(bucketName);
logger.info("Cleanup of bucket {} succeeded", bucketName);
} catch (AmazonClientException cleanupEx) {
logger.error("Cleanup of bucket {} also failed", bucketName, cleanupEx);
}
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:440
- Policy-revocation failures are swallowed here. If the original
PutUserPolicywas accepted but the client lost the response, the account policy can still contain this bucket ARN; the caller then invokesdeleteBucket, which removes the row before another policy refresh. If IAM remains unavailable, that second call fails after row removal, leaving stale tenant access (and unreleased resource reservations) with no CloudStack state to retry. Make this cleanup durable/retryable instead of losing the failure.
} catch (Exception policyEx) {
logger.warn("Failed to revoke IAM policy for bucket {} after cleanup: {}", bucketName, policyEx.getMessage());
}
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:732
ListObjectsV2returns only the current version of each key. Once CloudStack enables versioning, noncurrent versions still consume SeaweedFS storage but are omitted here, so the scheduled usage scan underreportsBucketVO.size/ObjectStoreVO.usedSizeand can make capacity accounting inaccurate. Use version-aware listing or a SeaweedFS usage source when versioning is enabled.
// Fallback: list objects per bucket via S3. This is O(total objects)
// and does not scale to large deployments; configure metricsUrl for
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:271
- Both
createUserandcreateBucketalready hold this store/account lock before calling this helper, so thisacquireIamLockcall re-enters the sameGlobalLock.GlobalLock.lock()emits a WARN for every re-entrant acquisition (framework/db/src/main/java/com/cloud/utils/db/GlobalLock.java:124-132), making normal bucket provisioning look like lock contention and polluting management-server logs. Split the locked and lock-free policy-refresh paths, or let callers retain lock ownership without reacquiring it.
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);
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:370
- The AWS SDK v1 signer already combines
request.getEndpoint().getPath()withrequest.getResourcePath()when building the canonical URI. PrefixingendpointPathintosignedResourcePaththerefore signs/object-s3/object-s3/bucketfor an endpoint such ashttps://host/object-s3, while the outgoing URI contains/object-s3/bucket; quota updates through path-prefixed reverse proxies will fail withSignatureDoesNotMatch. Keep the request resource path unprefixed and only prependendpointPathwhen constructingfullUri.
}
ui/src/views/infra/AddObjectStorage.vue:130
- Selecting SeaweedFS renders the generic form, which submits only
url,accesskey, andsecretkey; it never sends the provider's supporteds3UrloriamUrloverrides. The lifecycle then defaults both endpoints tourl, so a deployment with a separate IAM endpoint cannot be registered through this UI even though the backend explicitly supports that configuration. Add SeaweedFS-specific endpoint fields/serialization, or otherwise make the UI's supported endpoint model explicit.
providers: ['MinIO', 'Ceph', 'Cloudian HyperStore', 'SeaweedFS', 'Simulator'],
- Files reviewed: 15/15 changed files
- Comments generated: 1
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved issues affect bucket cleanup, IAM policy revocation, and usage-metrics reporting.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (3)
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:518
- This removes the CloudStack
BucketVObefore the IAM policy refresh can succeed. IfupdateAccountIAMPolicythen hits a transient IAM failure,deleteCheckedBucketnever reaches its resource-limit/allocated-size cleanup or its normal row removal, so the bucket ID cannot be retried while the old account policy may still grant this now-reusable ARN. Keep the row until the policy update succeeds, or roll back the row and accounting on failure while coordinating the concurrent policy rebuild.
for (BucketVO bvo : _bucketDao.listByObjectStoreIdAndAccountId(storeId, accountId)) {
if (bucketName.equals(bvo.getName())) {
_bucketDao.remove(bvo.getId());
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:439
- If this policy refresh fails, the exception is only logged and the original failure is rethrown, leaving the deleted bucket ARN in the account's IAM policy. If the name is later reused by another account, the old account's credentials can still access the new bucket; do not silently accept a failed revocation—retry/reconcile it or otherwise block reuse until the grant is removed.
try {
AmazonIdentityManagement iamClient = getIAMClient(storeId);
updateAccountIAMPolicy(iamClient, storeId, accountId, bucketName);
} catch (Exception policyEx) {
logger.warn("Failed to revoke IAM policy for bucket {} after cleanup: {}", bucketName, policyEx.getMessage());
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:482
- The exported SeaweedFS Prometheus series is
SeaweedFS_s3_bucket_size_bytes, but this parser looks forseaweed_s3_bucket_size_bytes. Prometheus metric names are case-sensitive, so configuringmetricsUrlwill yield no bucket samples and usage will be reported incorrectly. Use the actual exported name (or make the prefix configurable).
public static final String METRIC_BUCKET_SIZE_BYTES = "seaweed_s3_bucket_size_bytes";
- Files reviewed: 15/15 changed files
- Comments generated: 2
- Review effort level: Lite
Three fixes to SeaweedFSObjectStoreUtil: 1. The exported SeaweedFS Prometheus series is SeaweedFS_s3_bucket_size_bytes (Namespace="SeaweedFS"), but the parser looked for seaweed_s3_bucket_size_bytes. Prometheus metric names are case-sensitive, so configuring metricsUrl yielded no bucket samples and usage was reported incorrectly. 2. The AWS SDK v1 AWS4Signer already combines the endpoint path with the resource path via SdkHttpUtils.appendUri when building the canonical URI. Prepending endpointPath to signedResourcePath caused double-prefixing (/object-s3/object-s3/bucket) for path-prefixed endpoints, failing with SignatureDoesNotMatch. The resource path is now just /bucket; the signer prepends the endpoint path internally. 3. parseBucketUsageFromMetrics now initializes every managed bucket to zero before parsing the scrape response, so buckets missing from the metrics are overwritten with zero instead of leaving stale sizes from a previous scan in BucketApiServiceImpl.
…agate cleanup failures Three fixes to SeaweedFSObjectStoreDriverImpl: 1. Split updateAccountIAMPolicy into a locked public variant and a lock-free updateAccountIAMPolicyLocked. Callers that already hold the IAM lock (createUser, createBucket post-create, deleteBucket) now call the lock-free variant, avoiding the GlobalLock re-entrant acquisition warning that polluted management-server logs on every bucket provisioning. 2. deleteBucket now acquires the IAM lock before the S3 delete and policy refresh, making them atomic with respect to concurrent createUser/createBucket. The BucketVO row is left intact until the policy refresh succeeds, so a retry can find the bucket and BucketApiServiceImpl reaches its resource-counter decrement. The previous approach removed the row before the policy refresh, making a policy failure unrecoverable. 3. createBucket cleanup now propagates S3 delete and IAM policy revocation failures as suppressed exceptions instead of swallowing them, so the caller sees that cleanup was incomplete.
Update testGetAllBucketsUsageFromMetrics to use the actual exported metric name SeaweedFS_s3_bucket_size_bytes (capital SeaweedFS) matching the SeaweedFS Prometheus Namespace constant.
Selecting SeaweedFS now renders provider-specific fields for optional s3Url, iamUrl, and metricsUrl overrides instead of the generic form that only submitted url, accesskey, and secretkey. Deployments with a separate IAM endpoint or a Prometheus metrics endpoint can now be registered through the UI. Only non-empty fields are submitted so defaulted endpoints are not persisted as stale overrides.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved moderate issues remain in usage-scrape fallback, quota clearing, and metrics URL handling.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (4)
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:555
- A managed bucket's sample can be present but use a valid Prometheus numeric format that
Long.parseLongcannot consume (for example, a floating-point or scientific-notation value). Swallowing that error leaves the bucket at the preinitialized 0 and makesgetAllBucketsUsagereturn successfully, so the driver skips its S3 fallback and overwrites real usage with zero. Treat this as a scrape failure (or parse the full Prometheus number format) so the existing fallback is used.
} catch (NumberFormatException ignored) {
// Skip unparseable metric values
}
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:311
- Swallowing every 404/405 when
sizeGiB == 0also makes clearing an existing quota report success when the extension is unavailable. After an update from a positive quota, CloudStack will lower its DB/resource accounting while SeaweedFS retains the old quota/read-only state, leaving the two systems inconsistent. Only tolerate this for a known initial no-quota create, or propagate the error for quota clears.
if (sizeGiB == 0 && e.getMessage() != null
&& (e.getMessage().contains("status 404") || e.getMessage().contains("status 405"))
&& !e.getMessage().contains("NoSuchBucket")) {
org.apache.logging.log4j.LogManager.getLogger(SeaweedFSObjectStoreUtil.class)
.warn("SeaweedFS quota extension not available for bucket {}; skipping quota disable (quota is already off by default)", bucketName);
return;
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:517
- The documentation above allows
metricsUrlto point at a Prometheus server, but this always appends/metrics; a Prometheus server's/metricsexposes its own metrics rather than the scraped SeaweedFS bucket series. That response is still HTTP 200, so the driver returns zero for every bucket instead of falling back. Restrict this setting to a SeaweedFS exporter URL or query Prometheus's HTTP API when a server URL is supplied.
java.net.URI uri = java.net.URI.create(metricsUrl + "/metrics");
ui/src/views/infra/AddObjectStorage.vue:92
- These SeaweedFS-specific labels and placeholders are hard-coded in English, while the surrounding object-storage form uses
$t(...)and locale keys. This leaves the new provider configuration untranslated for non-English users; add locale entries and bind the labels/placeholders through the localization mechanism.
<a-form-item name="s3Url" ref="s3Url" label="S3 Endpoint URL (optional)">
- Files reviewed: 15/15 changed files
- Comments generated: 1
- Review effort level: Lite
| // Initialize all managed buckets to zero so missing samples do not | ||
| // leave stale sizes from a previous scan in BucketApiServiceImpl. | ||
| for (String name : bucketNames) { | ||
| result.put(name, 0L); | ||
| } |
Swallowing every 404/405 when sizeGiB == 0 also made clearing an existing quota report success when the extension is unavailable. After an update from a positive quota, CloudStack lowered its DB and resource accounting while SeaweedFS retained the old quota and read-only state, leaving the two systems inconsistent. setBucketQuotaViaS3Extension now takes an allowMissingExtension flag. The driver sets it only when the persisted BucketVO has no positive quota, i.e. the initial create path where CreateBucketCmd always calls setQuota with the requested value. A clear of an existing positive quota propagates the error.
…listing Two ways the metrics path could silently report zero usage instead of falling back to the S3 listing: 1. A sample value that Long.parseLong cannot consume (Prometheus gauges are floating point and may use scientific notation, e.g. 1.2345678e+07) was swallowed, leaving the bucket at the preinitialized zero while the scrape returned successfully. Values are now parsed as double and rounded, and an unparseable or non-finite value raises a scrape failure so the caller falls back. 2. metricsUrl pointing at a Prometheus server rather than a SeaweedFS S3 exporter returns HTTP 200 with Prometheus's own internal metrics, so no bucket samples matched and every bucket was reported as zero. The response is now validated to contain the metric family, and the documentation states metricsUrl must be a SeaweedFS S3 server's metrics port.
Four new tests: - testSetBucketQuotaClearExistingPropagates404: a quota clear on a bucket with an existing positive quota must not swallow a 404. - testGetAllBucketsUsageMetricsFloatValueParsed: scientific-notation gauge values are parsed correctly. - testGetAllBucketsUsageUnparseableMetricFallsBackToList: an unparseable sample triggers the S3 fallback instead of reporting zero. - testGetAllBucketsUsageWrongMetricsEndpointFallsBackToList: a metricsUrl pointing at a Prometheus server (HTTP 200, no SeaweedFS series) triggers the S3 fallback.
The SeaweedFS endpoint field labels and placeholders were hard-coded in English while the surrounding form uses $t(...) with locale keys, leaving the new provider configuration untranslated for non-English users. Added label.seaweedfs.s3.url, label.seaweedfs.iam.url, label.seaweedfs.metrics.url and matching .placeholder entries to en.json and bound the form fields through the localization mechanism.
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved moderate issues affect bucket consistency, API responses, and usage accuracy.
Review details
Suppressed comments (5)
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:557
- The lock is released here before
BucketApiServiceImplremoves theBucketVO. A concurrentcreateUser/createBucketcan acquire the lock after this block, observe the still-present row, and publish a policy that re-adds this bucket; the row is then removed, leaving a stale ARN grant. Since bucket names are reusable, the old account could access a later account's bucket with the same name. Keep row removal inside the same lock scope or otherwise serialize deletion with policy refresh.
} finally {
iamLock.unlock();
iamLock.releaseRef();
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:439
- Although this returns the updated
BucketVO,BucketApiServiceImpl.createBucketdiscards that return value and ultimately returns its originalbuckettoCreateBucketCmd. The response generator therefore sees null or staleaccessKey,secretKey, andbucketURLimmediately after a successful create, even though these fields are populated in the database here and are part ofBucketResponse. Propagate the returnedBucketVOinto the API response/state update.
// Return the updated BucketVO (not the stale input bucket) so
// BucketApiServiceImpl.createBucket does not overwrite the
// persisted credentials with the stale values.
return bucketVO;
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:774
- SeaweedFS refreshes
bucket_size_bytesonly on the S3 instance holding its distributeds3.leaderlock. IfmetricsUrlpoints to a load-balanced service, this request can hit a non-leader with stale or empty gauges; the parser then accepts those values and bypasses the S3 fallback, so CloudStack can under-report usage. Use a stable leader endpoint or an aggregated/freshness-checked metrics source.
try {
return SeaweedFSObjectStoreUtil.parseBucketUsageFromMetrics(
metricsUrl, bucketNames, getS3ExtensionHttpClient());
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:563
- This check also matches Prometheus
# HELPand# TYPEmetadata lines, so a response with no bucket samples is accepted. The result was prefilled with zeroes and is then returned without the S3 fallback, causing every managed bucket to be reported as 0 bytes during an exporter response that only contains the metric declaration. Require an actual sample line (or otherwise treat a sample-less family as a scrape failure).
if (!body.contains(METRIC_BUCKET_SIZE_BYTES)) {
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:547
- Appending
/metricsdirectly makes a configured URL ending in/request//metrics. That can return a redirect or 404;HttpClientdoes not follow redirects here, so the driver silently falls back to the O(total objects) S3 scan on every usage poll and loses the scalability benefit ofmetricsUrl. Normalize trailing slashes before appending the endpoint path.
java.net.URI uri = java.net.URI.create(metricsUrl + "/metrics");
- Files reviewed: 16/16 changed files
- Comments generated: 0 new
- Review effort level: Lite
Three ways the metrics path could bypass the S3 fallback and report zero usage for every managed bucket: 1. A metricsUrl ending in '/' produced a '//metrics' request, which can redirect or 404. HttpClient does not follow redirects here, so the driver silently fell back to the O(total objects) S3 scan on every usage poll, losing the scalability benefit. Trailing slashes are now normalized before appending the endpoint path. 2. The metric-family check matched Prometheus '# HELP' and '# TYPE' metadata lines, so a response declaring the family but exporting no samples was accepted and the prefilled zeroes were returned. Comment lines are now skipped. 3. SeaweedFS refreshes the bucket-size gauges only on the S3 instance holding the distributed s3.leader lock, so a load-balanced metricsUrl can hit a non-leader with empty gauges. Since SeaweedFS publishes a zero gauge for empty buckets, the leader always exports one sample per bucket it knows about; the parser now requires a sample for every managed bucket and raises a scrape failure otherwise, so the caller falls back to the accurate S3 listing.
The IAM lock was released before BucketApiServiceImpl removed the BucketVO row. A concurrent createUser/createBucket could acquire the lock in that window, observe the still-present row, and publish a policy that re-added the deleted bucket ARN; the row was then removed, leaving a stale grant. Since bucket names are reusable, the old account could access a later account's bucket with the same name. deleteBucket now removes the row itself, inside the lock and after the policy refresh succeeds. A policy failure still propagates with the row intact so the operation stays retryable and BucketApiServiceImpl leaves its accounting alone. On success BucketApiServiceImpl's own _bucketDao.remove is a no-op while its resource-limit and allocated-size cleanup still runs.
createBucket captured the driver's return value only as a BucketTO and then wrote the original in-memory bucket back with _bucketDao.update, clobbering the access key, secret key, and bucket URL that the provider had just persisted. The stale object was also returned to CreateBucketCmd, so BucketResponse exposed null credentials immediately after a successful create even though the database held the real values. The row is now re-read before the state update so provider-persisted fields survive and reach the API response. Providers such as SeaweedFS and Cloudian HyperStore write per-account credentials to the BucketVO themselves.
… removal Four new tests: - testDeleteBucketRemovesRowInsideLock: the driver removes the BucketVO itself so no concurrent policy rebuild can observe a stale row. - testGetAllBucketsUsageMetadataOnlyFallsBackToList: a HELP/TYPE-only response (non-leader S3 instance) triggers the S3 fallback. - testGetAllBucketsUsageMissingSampleFallsBackToList: a response missing a sample for one managed bucket triggers the S3 fallback. - testGetAllBucketsUsageMetricsUrlTrailingSlashNormalized: a metricsUrl with a trailing slash requests /metrics, not //metrics.
Summary
Adds SeaweedFS as a first-class object storage provider in CloudStack, alongside the existing MinIO, Ceph RGW, and Cloudian HyperStore providers.
SeaweedFS exposes an S3-compatible API and an AWS IAM-compatible API, so this provider uses the AWS S3 and IAM Java SDKs — the same approach as the Cloudian HyperStore provider. No proprietary admin client is needed.
Key features
AmazonS3SDK (AWS SDK v1, same as Ceph/Cloudian)AmazonIdentityManagementSDK (same as Cloudian HyperStore)?seaweedfs-quotaextension (PUT /{bucket}?seaweedfs-quota), authenticated via SigV4 and authorized via thes3:PutBucketQuotaIAM permission. This requires SeaweedFS PR Multiple guest networks deployment issue #11279 (merged).ListObjectsV2(MVP; Prometheus or SOSAPIcapacity.xmlrecommended for production scale)Architecture
The plugin follows the Cloudian HyperStore pattern almost line for line:
s3Url,iamUrl,accesskey,secretkeyiamUrldefaults tos3Url(SeaweedFS registers its IAM API atPOST /on the same S3 endpoint)Quota management
SeaweedFS enforces bucket quota server-side (read-only flag when usage exceeds the limit). The configuration surface is a narrow S3 subresource —
PUT /{bucket}?seaweedfs-quota— authenticated via standard S3 SigV4 and authorized via dedicateds3:PutBucketQuota/s3:GetBucketQuotaIAM permissions. This avoids exposing the broad SeaweedFS admin API to CloudStack. The plugin signs the request withAWSS3V4Signerand sends it viajava.net.http.HttpClient(the AWS S3 SDK doesn't natively support custom subresources).Comparison with MinIO and Ceph
MinioClientAmazonS3AmazonS3AmazonS3MinioAdminClientRgwAdminAmazonIdentityManagementAmazonIdentityManagementMinioAdminClientRgwAdminMinioAdminClientRgwAdminListObjectsV2Files
New module under
plugins/storage/object/seaweedfs/:pom.xml— Maven moduleSeaweedFSObjectStoreProviderImpl.java— Spring provider registrationSeaweedFSObjectStoreLifeCycleImpl.java— Pool add/health-check, URL validationSeaweedFSObjectStoreDriverImpl.java— Bucket + user ops via S3 + IAM SDKSeaweedFSObjectStoreUtil.java— S3 + IAM client builders, constants, SigV4 quota requestSeaweedFS dependency
Requires SeaweedFS with the
?seaweedfs-quotaS3 extension (PR seaweedfs/seaweedfs#11279, merged). Without it, quota operations will fail with 404; all other operations (bucket CRUD, user provisioning, usage) work with any recent SeaweedFS release.Test plan
mvn -pl plugins/storage/object/seaweedfs test(18 tests, 0 failures)addObjectStoragePoolwiths3Url,accesskey,secretkeyweed shells3.bucket.list