diff --git a/client/pom.xml b/client/pom.xml
index d8fa433d5be3..d86db979b4ee 100644
--- a/client/pom.xml
+++ b/client/pom.xml
@@ -632,6 +632,11 @@
cloud-plugin-storage-object-minio
${project.version}
+
+ org.apache.cloudstack
+ cloud-plugin-storage-object-ecs
+ ${project.version}
+
org.apache.cloudstack
cloud-plugin-storage-object-ceph
diff --git a/plugins/pom.xml b/plugins/pom.xml
index e7d13871285e..0fde420ab15f 100755
--- a/plugins/pom.xml
+++ b/plugins/pom.xml
@@ -140,6 +140,7 @@
storage/object/ceph
storage/object/cloudian
storage/object/simulator
+ storage/object/ECS
storage-allocators/random
diff --git a/plugins/storage/object/ECS/pom.xml b/plugins/storage/object/ECS/pom.xml
new file mode 100644
index 000000000000..c19ce1698aa9
--- /dev/null
+++ b/plugins/storage/object/ECS/pom.xml
@@ -0,0 +1,47 @@
+
+
+ 4.0.0
+ cloud-plugin-storage-object-ecs
+ Apache CloudStack Plugin - ECS object storage provider
+
+ org.apache.cloudstack
+ cloudstack-plugins
+ 4.23.0.0-SNAPSHOT
+ ../../../pom.xml
+
+
+
+ org.apache.cloudstack
+ cloud-engine-storage
+ ${project.version}
+
+
+ org.apache.cloudstack
+ cloud-engine-storage-object
+ ${project.version}
+
+
+ org.apache.cloudstack
+ cloud-engine-schema
+ ${project.version}
+
+
+
diff --git a/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsConstants.java b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsConstants.java
new file mode 100644
index 000000000000..b2f4bab2f352
--- /dev/null
+++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsConstants.java
@@ -0,0 +1,19 @@
+package org.apache.cloudstack.storage.datastore.driver;
+
+public final class EcsConstants {
+ private EcsConstants() {}
+
+ // Object store details keys
+ public static final String MGMT_URL = "mgmt_url";
+ public static final String SA_USER = "sa_user";
+ public static final String SA_PASS = "sa_password";
+ public static final String NAMESPACE = "namespace";
+ public static final String INSECURE = "insecure";
+ public static final String S3_HOST = "s3_host";
+ public static final String USER_PREFIX = "user_prefix";
+ public static final String DEFAULT_USER_PREFIX = "cs-";
+
+ // Per-account keys
+ public static final String AD_KEY_ACCESS = "ecs.accesskey";
+ public static final String AD_KEY_SECRET = "ecs.secretkey";
+}
diff --git a/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsMgmtTokenManager.java b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsMgmtTokenManager.java
new file mode 100644
index 000000000000..6a645d447d05
--- /dev/null
+++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsMgmtTokenManager.java
@@ -0,0 +1,142 @@
+package org.apache.cloudstack.storage.datastore.driver;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.auth.BasicScheme;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.util.EntityUtils;
+
+import com.cloud.utils.exception.CloudRuntimeException;
+
+public class EcsMgmtTokenManager {
+ private static final long DEFAULT_TOKEN_MAX_AGE_SEC = 300;
+ private static final long EXPIRY_SKEW_SEC = 30;
+
+ private static final ConcurrentHashMap TOKEN_CACHE = new ConcurrentHashMap<>();
+ private static final ConcurrentHashMap TOKEN_LOCKS = new ConcurrentHashMap<>();
+
+ static final class EcsUnauthorizedException extends RuntimeException {
+ EcsUnauthorizedException(final String msg) { super(msg); }
+ }
+
+ @FunctionalInterface
+ public interface WithToken { T run(String token) throws Exception; }
+
+ private static final class TokenKey {
+ final String mgmtUrl;
+ final String user;
+ TokenKey(final String mgmtUrl, final String user) {
+ this.mgmtUrl = mgmtUrl;
+ this.user = user;
+ }
+ @Override public boolean equals(final Object o) {
+ if (this == o) return true;
+ if (!(o instanceof TokenKey)) return false;
+ final TokenKey k = (TokenKey) o;
+ return Objects.equals(mgmtUrl, k.mgmtUrl) && Objects.equals(user, k.user);
+ }
+ @Override public int hashCode() { return Objects.hash(mgmtUrl, user); }
+ }
+
+ private static final class TokenEntry {
+ final String token;
+ final long expiresAtMs;
+ TokenEntry(final String token, final long expiresAtMs) {
+ this.token = token;
+ this.expiresAtMs = expiresAtMs;
+ }
+ boolean validNow() {
+ return token != null && !token.isBlank() && System.currentTimeMillis() < expiresAtMs;
+ }
+ }
+
+ public T callWithRetry401(final EcsObjectStoreDriverImpl.EcsCfg cfg,
+ final WithToken op,
+ final HttpClientFactory httpFactory) throws Exception {
+ try {
+ return op.run(getAuthToken(cfg, httpFactory));
+ } catch (EcsUnauthorizedException u) {
+ invalidate(cfg);
+ return op.run(getAuthToken(cfg, httpFactory));
+ }
+ }
+
+ public void invalidate(final EcsObjectStoreDriverImpl.EcsCfg cfg) {
+ TOKEN_CACHE.remove(new TokenKey(trimTail(cfg.mgmtUrl), cfg.saUser));
+ }
+
+ public String getAuthToken(final EcsObjectStoreDriverImpl.EcsCfg cfg,
+ final HttpClientFactory httpFactory) {
+ final String mu = trimTail(cfg.mgmtUrl);
+ final TokenKey key = new TokenKey(mu, cfg.saUser);
+
+ final TokenEntry cached = TOKEN_CACHE.get(key);
+ if (cached != null && cached.validNow()) return cached.token;
+
+ final Object lock = TOKEN_LOCKS.computeIfAbsent(key, k -> new Object());
+ synchronized (lock) {
+ final TokenEntry cached2 = TOKEN_CACHE.get(key);
+ if (cached2 != null && cached2.validNow()) return cached2.token;
+
+ final TokenEntry fresh = loginAndGetTokenFresh(mu, cfg.saUser, cfg.saPass, cfg.insecure, httpFactory);
+ TOKEN_CACHE.put(key, fresh);
+ return fresh.token;
+ }
+ }
+
+ private TokenEntry loginAndGetTokenFresh(final String mgmtUrl,
+ final String user,
+ final String pass,
+ final boolean insecure,
+ final HttpClientFactory httpFactory) {
+ try (CloseableHttpClient http = httpFactory.build(insecure)) {
+ final HttpGet get = new HttpGet(mgmtUrl + "/login");
+ UsernamePasswordCredentials creds = new UsernamePasswordCredentials(user, pass);
+ get.addHeader(new BasicScheme().authenticate(creds, get, null));
+
+ try (CloseableHttpResponse resp = http.execute(get)) {
+ final int status = resp.getStatusLine().getStatusCode();
+ if (status != 200 && status != 201) {
+ final String body = resp.getEntity() != null
+ ? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8)
+ : "";
+ throw new CloudRuntimeException("ECS /login failed: HTTP " + status + " body=" + body);
+ }
+ if (resp.getFirstHeader("X-SDS-AUTH-TOKEN") == null) {
+ throw new CloudRuntimeException("ECS /login did not return X-SDS-AUTH-TOKEN header");
+ }
+
+ final String token = resp.getFirstHeader("X-SDS-AUTH-TOKEN").getValue();
+
+ long maxAgeSec = DEFAULT_TOKEN_MAX_AGE_SEC;
+ try {
+ if (resp.getFirstHeader("X-SDS-AUTH-MAX-AGE") != null) {
+ maxAgeSec = Long.parseLong(resp.getFirstHeader("X-SDS-AUTH-MAX-AGE").getValue().trim());
+ }
+ } catch (Exception ignore) { }
+
+ final long effectiveSec = Math.max(5, maxAgeSec - EXPIRY_SKEW_SEC);
+ final long expiresAtMs = System.currentTimeMillis() + (effectiveSec * 1000L);
+ return new TokenEntry(token, expiresAtMs);
+ }
+ } catch (Exception e) {
+ throw new CloudRuntimeException("Failed to obtain ECS auth token: " + e.getMessage(), e);
+ }
+ }
+
+ private static String trimTail(final String s) {
+ if (s == null) return null;
+ return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
+ }
+
+ /** Simple seam for testability; implemented by the driver using its existing buildHttpClient(). */
+ @FunctionalInterface
+ public interface HttpClientFactory {
+ CloseableHttpClient build(boolean insecure);
+ }
+}
diff --git a/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsObjectStoreDriverImpl.java b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsObjectStoreDriverImpl.java
new file mode 100644
index 000000000000..03515638bdeb
--- /dev/null
+++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsObjectStoreDriverImpl.java
@@ -0,0 +1,1560 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.cloudstack.storage.datastore.driver;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import javax.inject.Inject;
+import javax.net.ssl.SSLContext;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.cloudstack.context.CallContext;
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
+import org.apache.cloudstack.storage.datastore.db.ObjectStoreDetailsDao;
+import org.apache.cloudstack.storage.object.BaseObjectStoreDriverImpl;
+import org.apache.cloudstack.storage.object.Bucket;
+import org.apache.cloudstack.storage.object.BucketObject;
+
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpDelete;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpPut;
+import org.apache.http.conn.ssl.NoopHostnameVerifier;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.ssl.SSLContextBuilder;
+import org.apache.http.ssl.TrustStrategy;
+import org.apache.http.util.EntityUtils;
+
+import com.amazonaws.services.s3.model.AccessControlList;
+import com.amazonaws.services.s3.model.BucketPolicy;
+import com.cloud.agent.api.to.BucketTO;
+import com.cloud.agent.api.to.DataStoreTO;
+import com.cloud.exception.InvalidParameterValueException;
+import com.cloud.storage.BucketVO;
+import com.cloud.storage.dao.BucketDao;
+import com.cloud.user.Account;
+import com.cloud.user.AccountDetailVO;
+import com.cloud.user.AccountDetailsDao;
+import com.cloud.user.dao.AccountDao;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl {
+
+ // ---- Injected dependencies ----
+ @Inject private AccountDao accountDao;
+ @Inject private AccountDetailsDao accountDetailsDao;
+ @Inject private BucketDao bucketDao;
+ @Inject private ObjectStoreDetailsDao storeDetailsDao;
+
+ private final EcsMgmtTokenManager tokenManager = new EcsMgmtTokenManager();
+ private final EcsXmlParser xml = new EcsXmlParser();
+
+ // Versioning retry (ECS can be eventually consistent)
+ private static final int VERSIONING_MAX_TRIES = 45;
+ private static final long VERSIONING_RETRY_SLEEP_MS = 1000L;
+
+ public EcsObjectStoreDriverImpl() {
+ }
+
+ @Override
+ public DataStoreTO getStoreTO(final DataStore store) {
+ return null;
+ }
+
+ // ---------------- create bucket ----------------
+
+ @Override
+ public Bucket createBucket(final Bucket bucket, final boolean objectLock) {
+ final long storeId = bucket.getObjectStoreId();
+ final String name = bucket.getName();
+
+ if (objectLock) {
+ throw new InvalidParameterValueException("Dell ECS doesn't support this feature: object locking");
+ }
+
+ final Map ds = storeDetailsDao.getDetails(storeId);
+ final EcsCfg cfg = ecsCfgFromDetails(ds, storeId);
+
+ final BucketVO vo = bucketDao.findById(bucket.getId());
+ if (vo == null) {
+ throw new CloudRuntimeException("ECS createBucket: bucket record not found: id=" + bucket.getId());
+ }
+
+ final long accountId = vo.getAccountId();
+ final Account acct = accountDao.findById(accountId);
+ if (acct == null) {
+ throw new CloudRuntimeException("ECS createBucket: account not found: id=" + accountId);
+ }
+
+ final String ownerUser = getUserPrefix(ds) + acct.getUuid();
+
+ // Ensure per-account credentials exist (single-key policy with adopt-if-exists)
+ ensureAccountUserAndSecret(accountId, ownerUser, cfg.mgmtUrl, cfg.saUser, cfg.saPass, cfg.ns, cfg.insecure);
+
+ // Quota from UI (INT GB). Bucket.getQuota may be Integer; Bucket.getSize may be Long.
+ Integer quotaGb = null;
+ try {
+ quotaGb = bucket.getQuota();
+ } catch (Throwable ignored) {
+ }
+
+ if (quotaGb == null) {
+ try {
+ final Long sz = bucket.getSize();
+ if (sz != null) {
+ quotaGb = sz.intValue();
+ }
+ } catch (Throwable ignored) {
+ }
+ }
+
+ final int blockSizeGb;
+ final int notifSizeGb;
+ if (quotaGb != null && quotaGb > 0) {
+ blockSizeGb = quotaGb;
+ notifSizeGb = quotaGb;
+ } else {
+ blockSizeGb = 2;
+ notifSizeGb = 1;
+ }
+
+ // Encryption flag from request (Bucket has isEncryption()).
+ boolean encryptionEnabled = bucket.isEncryption();
+
+ // Fallback to persisted value if request did not explicitly enable it.
+ if (!encryptionEnabled) {
+ try {
+ encryptionEnabled = vo.isEncryption();
+ } catch (Throwable ignored) {
+ }
+ }
+
+ logger.info("ECS createBucket flags for '{}': encryptionEnabled={}", name, encryptionEnabled);
+
+ final String createBody =
+ ""
+ + "" + blockSizeGb + ""
+ + "" + notifSizeGb + ""
+ + "" + name + ""
+ + "s3"
+ + "" + cfg.ns + ""
+ + "" + ownerUser + ""
+ + "" + (encryptionEnabled ? "true" : "false") + ""
+ + "";
+
+ if (logger.isDebugEnabled()) {
+ logger.debug("ECS createBucket XML for '{}': {}", name, createBody);
+ }
+
+ try {
+ tokenManager.callWithRetry401(cfg, token -> {
+ try (CloseableHttpClient http = buildHttpClient(cfg.insecure)) {
+ final HttpPost post = new HttpPost(cfg.mgmtUrl + "/object/bucket");
+ post.setHeader("X-SDS-AUTH-TOKEN", token);
+ post.setHeader("Content-Type", "application/xml");
+ post.setEntity(new StringEntity(createBody, StandardCharsets.UTF_8));
+
+ try (CloseableHttpResponse resp = http.execute(post)) {
+ final int status = resp.getStatusLine().getStatusCode();
+ final String respBody = resp.getEntity() != null
+ ? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8)
+ : "";
+
+ if (status == 401) {
+ throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS createBucket got 401");
+ }
+
+ if (status != 200 && status != 201) {
+ String reason = "HTTP " + status;
+ if (status == 400 && xml.looksLikeBucketAlreadyExists400(respBody)) {
+ reason = "HTTP 400 bucket name already exists";
+ }
+ logger.error("ECS create bucket failed: {} body={}", reason, respBody);
+ throw new CloudRuntimeException("Failed to create ECS bucket " + name + ": " + reason);
+ }
+ }
+ }
+ return null;
+ }, this::buildHttpClient);
+
+ // UI URL should show S3 endpoint
+ final String s3Host = resolveS3HostForUI(storeId, ds);
+ final String s3UrlForUI = "https://" + s3Host + "/" + name;
+
+ logger.info("ECS bucket created: name='{}' owner='{}' ns='{}' quota={}GB enc={} (UI URL: {})",
+ name, ownerUser, cfg.ns, (quotaGb != null ? quotaGb : blockSizeGb), encryptionEnabled, s3UrlForUI);
+
+ // Persist UI-visible details on the bucket record
+ final String accKey = valueOrNull(accountDetailsDao.findDetail(accountId, EcsConstants.AD_KEY_ACCESS));
+ final String secKey = valueOrNull(accountDetailsDao.findDetail(accountId, EcsConstants.AD_KEY_SECRET));
+
+ vo.setBucketURL(s3UrlForUI);
+
+ if (!StringUtils.isBlank(accKey)) {
+ vo.setAccessKey(accKey);
+ }
+
+ if (!StringUtils.isBlank(secKey)) {
+ vo.setSecretKey(secKey);
+ }
+
+ bucketDao.update(vo.getId(), vo);
+
+ // NOTE: Do NOT attempt to enable versioning here unless you have a reliable signal in your CloudStack
+ // version. The provided code previously referenced missing methods and broke compilation.
+
+ return bucket;
+
+ } catch (CloudRuntimeException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new CloudRuntimeException("Failed to create ECS bucket " + name + ": " + e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public boolean createUser(final long accountId, final long storeId) {
+ final Account acct = accountDao.findById(accountId);
+ if (acct == null) {
+ throw new CloudRuntimeException("ECS createUser: account not found: id=" + accountId);
+ }
+
+ final Map ds = storeDetailsDao.getDetails(storeId);
+ final EcsCfg cfg = ecsCfgFromDetails(ds, storeId);
+
+ final String username = getUserPrefix(ds) + acct.getUuid();
+ ensureAccountUserAndSecret(accountId, username, cfg.mgmtUrl, cfg.saUser, cfg.saPass, cfg.ns, cfg.insecure);
+ return true;
+ }
+
+ // ---------------- S3: list buckets (SigV2, path-style GET /) ----------------
+ @Override
+ public List listBuckets(final long storeId) {
+ final Map ds = storeDetailsDao.getDetails(storeId);
+
+ final CallContext ctx = CallContext.current();
+ if (ctx == null || ctx.getCallingAccount() == null) {
+ throw new CloudRuntimeException("ECS listBuckets: no calling account in context.");
+ }
+
+ final long accountId = ctx.getCallingAccount().getId();
+ final String accessKey = valueOrNull(accountDetailsDao.findDetail(accountId, EcsConstants.AD_KEY_ACCESS));
+ final String secretKey = valueOrNull(accountDetailsDao.findDetail(accountId, EcsConstants.AD_KEY_SECRET));
+
+ if (StringUtils.isBlank(accessKey) || StringUtils.isBlank(secretKey)) {
+ throw new CloudRuntimeException("ECS listBuckets: account has no stored S3 credentials");
+ }
+
+ final S3Endpoint ep = resolveS3Endpoint(ds, storeId);
+ if (ep == null || StringUtils.isBlank(ep.host)) {
+ throw new CloudRuntimeException("ECS listBuckets: S3 endpoint not resolvable");
+ }
+
+ final boolean insecure = "true".equalsIgnoreCase(ds.getOrDefault(EcsConstants.INSECURE, "false"));
+ final java.util.List out = new java.util.ArrayList<>();
+
+ try (CloseableHttpClient http = buildHttpClient(insecure)) {
+ final String dateHdr = rfc1123Now();
+ final String canonicalResource = "/"; // ListAllMyBuckets
+ final String sts = "GET\n\n\n" + dateHdr + "\n" + canonicalResource;
+ final String signature = hmacSha1Base64(sts, secretKey);
+
+ final String url = ep.scheme + "://" + ep.host + "/";
+ final HttpGet get = new HttpGet(url);
+ get.setHeader("Host", ep.host);
+ get.setHeader("Date", dateHdr);
+ get.setHeader("Authorization", "AWS " + accessKey + ":" + signature);
+
+ try (CloseableHttpResponse resp = http.execute(get)) {
+ final int st = resp.getStatusLine().getStatusCode();
+ final String body = resp.getEntity() != null
+ ? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8)
+ : "";
+
+ if (st != 200) {
+ logger.error("ECS listBuckets failed: HTTP {} body={}", st, body);
+ throw new CloudRuntimeException("ECS listBuckets failed: HTTP " + st);
+ }
+
+ final List names = xml.extractAllTags(body, "Name");
+ for (String n : names) {
+ if (StringUtils.isBlank(n)) {
+ continue;
+ }
+ final Bucket b = new BucketObject();
+ b.setName(n.trim());
+ out.add(b);
+ }
+ }
+ } catch (Exception e) {
+ throw new CloudRuntimeException("ECS listBuckets failed: " + e.getMessage(), e);
+ }
+
+ return out;
+ }
+
+ // ---------------- S3: list objects in a bucket (SigV2, path-style) ----------------
+ public List listBucketObjects(final String bucketName, final long storeId) {
+ final Map ds = storeDetailsDao.getDetails(storeId);
+
+ final CallContext ctx = CallContext.current();
+ if (ctx == null || ctx.getCallingAccount() == null) {
+ throw new CloudRuntimeException("ECS listBucketObjects: no calling account in context");
+ }
+
+ final long accountId = ctx.getCallingAccount().getId();
+ final String accessKey = valueOrNull(accountDetailsDao.findDetail(accountId, EcsConstants.AD_KEY_ACCESS));
+ final String secretKey = valueOrNull(accountDetailsDao.findDetail(accountId, EcsConstants.AD_KEY_SECRET));
+
+ if (StringUtils.isBlank(accessKey) || StringUtils.isBlank(secretKey)) {
+ throw new CloudRuntimeException("ECS listBucketObjects: account has no stored S3 credentials");
+ }
+
+ final S3Endpoint ep = resolveS3Endpoint(ds, storeId);
+ if (ep == null || StringUtils.isBlank(ep.host)) {
+ throw new CloudRuntimeException("ECS listBucketObjects: S3 endpoint not resolvable");
+ }
+
+ final boolean insecure = "true".equalsIgnoreCase(ds.getOrDefault(EcsConstants.INSECURE, "false"));
+ final List keys = new java.util.ArrayList<>();
+
+ String marker = null;
+ try (CloseableHttpClient http = buildHttpClient(insecure)) {
+ while (true) {
+ final String dateHdr = rfc1123Now();
+ final String canonicalResource = "/" + bucketName + "/";
+ final String sts = "GET\n\n\n" + dateHdr + "\n" + canonicalResource;
+ final String signature = hmacSha1Base64(sts, secretKey);
+
+ final StringBuilder qs = new StringBuilder("max-keys=1000");
+ if (!StringUtils.isBlank(marker)) {
+ qs.append("&marker=").append(java.net.URLEncoder.encode(
+ marker, java.nio.charset.StandardCharsets.UTF_8.name()));
+ }
+
+ final String url = ep.scheme + "://" + ep.host + "/" + bucketName + "/?" + qs;
+ final HttpGet get = new HttpGet(url);
+ get.setHeader("Host", ep.host);
+ get.setHeader("Date", dateHdr);
+ get.setHeader("Authorization", "AWS " + accessKey + ":" + signature);
+
+ try (CloseableHttpResponse resp = http.execute(get)) {
+ final int st = resp.getStatusLine().getStatusCode();
+ final String body = resp.getEntity() != null
+ ? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8)
+ : "";
+
+ if (st != 200) {
+ logger.error("ECS listBucketObjects '{}' failed: HTTP {} body={}", bucketName, st, body);
+ throw new CloudRuntimeException("ECS listBucketObjects failed: HTTP " + st);
+ }
+
+ xml.extractKeysFromListBucketXml(body, keys);
+
+ final boolean truncated = "true".equalsIgnoreCase(xml.extractTag(body, "IsTruncated"));
+ if (!truncated) {
+ break;
+ }
+
+ String next = xml.extractTag(body, "NextMarker");
+ if (StringUtils.isBlank(next) && !keys.isEmpty()) {
+ next = keys.get(keys.size() - 1);
+ }
+ if (StringUtils.isBlank(next)) {
+ break;
+ }
+
+ marker = next;
+ }
+ }
+ } catch (Exception e) {
+ throw new CloudRuntimeException("ECS listBucketObjects failed: " + e.getMessage(), e);
+ }
+
+ return keys;
+ }
+
+ // ---------------- delete bucket (Mgmt API) ----------------
+ @Override
+ public boolean deleteBucket(final BucketTO bucket, final long storeId) {
+ final String bucketName = bucket.getName();
+
+ final Map ds = storeDetailsDao.getDetails(storeId);
+ final EcsCfg cfg = ecsCfgFromDetails(ds, storeId);
+
+ final String url = cfg.mgmtUrl + "/object/bucket/" + bucketName + "/deactivate?namespace=" + cfg.ns;
+
+ try {
+ return tokenManager.callWithRetry401(cfg, token -> {
+ try (CloseableHttpClient http = buildHttpClient(cfg.insecure)) {
+ final HttpPost post = new HttpPost(url);
+ post.setHeader("X-SDS-AUTH-TOKEN", token);
+
+ try (CloseableHttpResponse r = http.execute(post)) {
+ final int st = r.getStatusLine().getStatusCode();
+ final String body = r.getEntity() != null
+ ? EntityUtils.toString(r.getEntity(), StandardCharsets.UTF_8)
+ : "";
+
+ if (st == 401) {
+ throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS deleteBucket got 401");
+ }
+
+ if (st == 200 || st == 204) {
+ logger.info("ECS bucket deactivated (deleted): '{}'", bucketName);
+ return true;
+ }
+
+ final String lb = body.toLowerCase(Locale.ROOT);
+ if (st == 400 || st == 409) {
+ if (lb.contains("not empty") || lb.contains("keypool not empty") || lb.contains("60019")) {
+ throw new CloudRuntimeException("Cannot delete bucket '" + bucketName + "': bucket is not empty");
+ }
+ }
+
+ if (st == 404) {
+ logger.info("ECS deleteBucket: '{}' not found; treating as already deleted.", bucketName);
+ return true;
+ }
+
+ logger.error("ECS delete bucket '{}' failed: HTTP {} body={}", bucketName, st, body);
+ throw new CloudRuntimeException("Failed to delete ECS bucket '" + bucketName + "': HTTP " + st);
+ }
+ }
+ }, this::buildHttpClient);
+ } catch (CloudRuntimeException cre) {
+ throw cre;
+ } catch (Exception e) {
+ throw new CloudRuntimeException("Failed to delete ECS bucket '" + bucketName + "': " + e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public AccessControlList getBucketAcl(final BucketTO bucket, final long storeId) {
+ return null;
+ }
+
+ @Override
+ public void setBucketAcl(final BucketTO bucket, final AccessControlList acl, final long storeId) {
+ // not supported
+ }
+
+ // ---------------- Policy ----------------
+ @Override
+ public void setBucketPolicy(final BucketTO bucket, final String policy, final long storeId) {
+ final String b = bucket.getName();
+
+ final Map ds = storeDetailsDao.getDetails(storeId);
+ final EcsCfg cfg = ecsCfgFromDetails(ds, storeId);
+
+ final String url;
+ try {
+ url = cfg.mgmtUrl + "/object/bucket/" + b + "/policy?namespace="
+ + java.net.URLEncoder.encode(cfg.ns, java.nio.charset.StandardCharsets.UTF_8.name());
+ } catch (Exception e) {
+ throw new CloudRuntimeException("ECS setBucketPolicy: failed to encode namespace", e);
+ }
+
+ final String req = policy == null ? "" : policy.trim();
+ final boolean wantPublic = "public".equalsIgnoreCase(req) || "public-read".equalsIgnoreCase(req);
+ final boolean wantPrivate = req.isEmpty() || "{}".equals(req) || "private".equalsIgnoreCase(req);
+
+ if (!wantPublic && !wantPrivate && !req.startsWith("{")) {
+ throw new CloudRuntimeException("ECS setBucketPolicy: unsupported policy value '" + policy
+ + "'. Use 'public', 'private', or raw JSON.");
+ }
+
+ try {
+ tokenManager.callWithRetry401(cfg, token -> {
+ final String current = getBucketPolicyRaw(url, token, cfg.insecure); // "" if none
+ final boolean hasPolicy = current != null && !current.isBlank();
+
+ if (wantPrivate) {
+ if (!hasPolicy) {
+ logger.info("ECS setBucketPolicy: already private (no policy). bucket='{}'", b);
+ return null;
+ }
+ deleteBucketPolicyHttp(url, token, cfg.insecure);
+ logger.info("ECS setBucketPolicy: removed policy via DELETE. bucket='{}'", b);
+ return null;
+ }
+
+ if (wantPublic && hasPolicy) {
+ logger.info("ECS setBucketPolicy: policy already present; leaving as-is. bucket='{}'", b);
+ return null;
+ }
+
+ final String policyJson;
+ if (req.startsWith("{")) {
+ policyJson = req;
+ } else {
+ policyJson = "{\n"
+ + " \"Version\":\"2012-10-17\",\n"
+ + " \"Statement\":[{\n"
+ + " \"Sid\":\"PublicReadGetObject\",\n"
+ + " \"Effect\":\"Allow\",\n"
+ + " \"Principal\":\"*\",\n"
+ + " \"Action\":[\"s3:GetObject\"],\n"
+ + " \"Resource\":[\"arn:aws:s3:::" + b + "/*\"]\n"
+ + " }]\n"
+ + "}";
+ }
+
+ putBucketPolicy(url, token, policyJson, cfg.insecure);
+ logger.info("ECS setBucketPolicy: applied policy (bucket='{}').", b);
+ return null;
+ }, this::buildHttpClient);
+ } catch (CloudRuntimeException cre) {
+ throw cre;
+ } catch (Exception e) {
+ throw new CloudRuntimeException("ECS setBucketPolicy error for bucket '" + b + "': " + e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public BucketPolicy getBucketPolicy(final BucketTO bucket, final long storeId) {
+ final String bucketName = bucket.getName();
+
+ final Map ds = storeDetailsDao.getDetails(storeId);
+ final EcsCfg cfg = ecsCfgFromDetails(ds, storeId);
+
+ final String url;
+ try {
+ url = cfg.mgmtUrl + "/object/bucket/" + bucketName + "/policy?namespace="
+ + java.net.URLEncoder.encode(cfg.ns, java.nio.charset.StandardCharsets.UTF_8.name());
+ } catch (Exception e) {
+ throw new CloudRuntimeException("ECS getBucketPolicy: failed to encode namespace", e);
+ }
+
+ try {
+ return tokenManager.callWithRetry401(cfg, token -> {
+ try (CloseableHttpClient http = buildHttpClient(cfg.insecure)) {
+ final HttpGet get = new HttpGet(url);
+ get.setHeader("X-SDS-AUTH-TOKEN", token);
+
+ try (CloseableHttpResponse resp = http.execute(get)) {
+ final int st = resp.getStatusLine().getStatusCode();
+ if (st == 401) {
+ throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS getBucketPolicy got 401");
+ }
+
+ final String body = resp.getEntity() == null ? "" :
+ EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8).trim();
+
+ final BucketPolicy bp = new BucketPolicy();
+ if (st == 200) {
+ bp.setPolicyText((body.isEmpty() || "{}".equals(body)) ? "{}" : body);
+ return bp;
+ }
+ if (st == 204 || st == 404 || ((st / 100) == 2 && body.isEmpty())) {
+ bp.setPolicyText("{}");
+ return bp;
+ }
+
+ throw new CloudRuntimeException("ECS getBucketPolicy failed: HTTP " + st + " body=" + body);
+ }
+ }
+ }, this::buildHttpClient);
+ } catch (Exception e) {
+ if (e instanceof CloudRuntimeException) {
+ throw (CloudRuntimeException) e;
+ }
+ throw new CloudRuntimeException("ECS getBucketPolicy error: " + e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public void deleteBucketPolicy(final BucketTO bucket, final long storeId) {
+ setBucketPolicy(bucket, "{}", storeId);
+ }
+
+ // --- Encryption (post-create): ECS cannot change it after creation ---
+ @Override
+ public boolean setBucketEncryption(final BucketTO bucket, final long storeId) {
+ final String bucketName = bucket != null ? bucket.getName() : "";
+ logger.info("ECS setBucketEncryption('{}'): ECS only supports encryption at bucket creation. Treating as no-op.", bucketName);
+ return true;
+ }
+
+ @Override
+ public boolean deleteBucketEncryption(final BucketTO bucket, final long storeId) {
+ final String bucketName = bucket != null ? bucket.getName() : "";
+ final String msg =
+ "Dell ECS bucket encryption can only be chosen at bucket creation; "
+ + "it cannot be disabled afterwards (bucket=" + bucketName + ")";
+ logger.error("ECS deleteBucketEncryption('{}') requested but {}", bucketName, msg);
+ throw new CloudRuntimeException(msg);
+ }
+
+ // ---------------- Versioning ----------------
+
+ @Override
+ public boolean setBucketVersioning(final BucketTO bucket, final long storeId) {
+ return setOrSuspendVersioning(bucket, storeId, true);
+ }
+
+ @Override
+ public boolean deleteBucketVersioning(final BucketTO bucket, final long storeId) {
+ return setOrSuspendVersioning(bucket, storeId, false);
+ }
+
+ private boolean setOrSuspendVersioning(final BucketTO bucket, final long storeId, final boolean enable) {
+ final Map ds = storeDetailsDao.getDetails(storeId);
+ final S3Endpoint ep = resolveS3Endpoint(ds, storeId);
+ final boolean insecure = "true".equalsIgnoreCase(ds.getOrDefault(EcsConstants.INSECURE, "false"));
+
+ if (ep == null || StringUtils.isBlank(ep.host)) {
+ logger.warn("ECS: {}BucketVersioning requested but S3 endpoint is not resolvable; skipping.",
+ enable ? "set" : "delete");
+ return true;
+ }
+
+ final String bucketName = bucket.getName();
+ final String desired = enable ? "Enabled" : "Suspended";
+
+ // First try: use calling account (normal API usage)
+ final CallContext ctx = CallContext.current();
+ long accountId = -1L;
+ if (ctx != null && ctx.getCallingAccount() != null) {
+ accountId = ctx.getCallingAccount().getId();
+ }
+
+ // Fallback: bucket VO may contain accountId (depends on CloudStack version & call path)
+ if (accountId <= 0) {
+ final BucketVO vo = resolveBucketVO(bucket, storeId);
+ if (vo != null) {
+ try { accountId = vo.getAccountId(); } catch (Throwable ignore) { }
+ }
+ }
+
+ // Fallback: reflection on BucketTO (if present in this branch)
+ if (accountId <= 0) {
+ accountId = getLongFromGetter(bucket, "getAccountId", -1L);
+ }
+
+ // Fallback: query ECS mgmt API for owner -> account
+ if (accountId <= 0) {
+ final Long aid = resolveAccountIdViaMgmt(bucketName, ds, insecure);
+ if (aid != null && aid > 0) {
+ accountId = aid;
+ }
+ }
+
+ if (accountId <= 0) {
+ logger.warn("ECS: cannot resolve accountId for bucket='{}'; skipping versioning request.", bucketName);
+ return true;
+ }
+
+ for (int attempt = 1; attempt <= VERSIONING_MAX_TRIES; attempt++) {
+ String accessKey = valueOrNull(accountDetailsDao.findDetail(accountId, EcsConstants.AD_KEY_ACCESS));
+ String secretKey = valueOrNull(accountDetailsDao.findDetail(accountId, EcsConstants.AD_KEY_SECRET));
+
+ // If missing, try to provision now
+ if (StringUtils.isBlank(accessKey) || StringUtils.isBlank(secretKey)) {
+ try {
+ final EcsCfg cfg = ecsCfgFromDetails(ds, storeId);
+ final Account acct = accountDao.findById(accountId);
+ if (acct != null) {
+ final String ownerUser = getUserPrefix(ds) + acct.getUuid();
+ ensureAccountUserAndSecret(accountId, ownerUser, cfg.mgmtUrl, cfg.saUser, cfg.saPass, cfg.ns, cfg.insecure);
+ accessKey = valueOrNull(accountDetailsDao.findDetail(accountId, EcsConstants.AD_KEY_ACCESS));
+ secretKey = valueOrNull(accountDetailsDao.findDetail(accountId, EcsConstants.AD_KEY_SECRET));
+ }
+ } catch (Exception e) {
+ logger.debug("ECS: ensureAccountUserAndSecret failed during versioning (attempt {}): {}", attempt, e.getMessage());
+ }
+ }
+
+ if (!StringUtils.isBlank(accessKey) && !StringUtils.isBlank(secretKey)) {
+ try (CloseableHttpClient http = buildHttpClient(insecure)) {
+ setS3BucketVersioningWithVerify(http, ep.scheme, ep.host, bucketName, accessKey, secretKey, desired);
+ logger.info("ECS: S3 versioning {} for bucket='{}' (accountId={}) succeeded on attempt {}/{}.",
+ desired, bucketName, accountId, attempt, VERSIONING_MAX_TRIES);
+ return true;
+ } catch (Exception e) {
+ logger.warn("ECS: versioning {} for '{}' failed on attempt {}/{}: {}",
+ desired, bucketName, attempt, VERSIONING_MAX_TRIES, e.getMessage());
+ }
+ } else {
+ logger.debug("ECS: missing S3 keys for accountId={} (attempt {}/{}).", accountId, attempt, VERSIONING_MAX_TRIES);
+ }
+
+ if (attempt < VERSIONING_MAX_TRIES) {
+ try {
+ Thread.sleep(VERSIONING_RETRY_SLEEP_MS);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ return true;
+ }
+ }
+ }
+
+ logger.warn("ECS: versioning {} for '{}' gave up after {} attempts; leaving as-is.",
+ desired, bucketName, VERSIONING_MAX_TRIES);
+ return true;
+ }
+
+ // ----- S3 Versioning (SigV2 path-style) -----
+
+ private void setS3BucketVersioning(final CloseableHttpClient http,
+ final String scheme,
+ final String host,
+ final String bucketName,
+ final String accessKey,
+ final String secretKey,
+ final String status) throws Exception {
+ final String body =
+ ""
+ + "" + status + ""
+ + "";
+
+ final byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);
+ final String contentType = "application/xml";
+ final String contentMd5 = base64Md5(bodyBytes);
+ final String dateHdr = rfc1123Now();
+
+ // IMPORTANT: include trailing slash before subresource
+ final String canonicalResource = "/" + bucketName + "/?versioning";
+ final String sts = "PUT\n" + contentMd5 + "\n" + contentType + "\n" + dateHdr + "\n" + canonicalResource;
+ final String signature = hmacSha1Base64(sts, secretKey);
+
+ final String url = scheme + "://" + host + "/" + bucketName + "/?versioning";
+ final HttpPut put = new HttpPut(url);
+ put.setHeader("Host", host);
+ put.setHeader("Date", dateHdr);
+ put.setHeader("Authorization", "AWS " + accessKey + ":" + signature);
+ put.setHeader("Content-Type", contentType);
+ put.setHeader("Content-MD5", contentMd5);
+ put.setEntity(new StringEntity(body, StandardCharsets.UTF_8));
+
+ try (CloseableHttpResponse resp = http.execute(put)) {
+ final int st = resp.getStatusLine().getStatusCode();
+ final String rb = resp.getEntity() != null
+ ? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8)
+ : "";
+
+ if (st != 200 && st != 204) {
+ throw new CloudRuntimeException("S3 versioning " + status + " failed: HTTP " + st + " body=" + rb);
+ }
+ }
+ }
+
+ private String getS3BucketVersioningStatus(final CloseableHttpClient http,
+ final String scheme,
+ final String host,
+ final String bucketName,
+ final String accessKey,
+ final String secretKey) throws Exception {
+ final String dateHdr = rfc1123Now();
+ final String canonicalResource = "/" + bucketName + "/?versioning";
+ final String sts = "GET\n\n\n" + dateHdr + "\n" + canonicalResource;
+ final String signature = hmacSha1Base64(sts, secretKey);
+
+ final String url = scheme + "://" + host + "/" + bucketName + "/?versioning";
+ final HttpGet get = new HttpGet(url);
+ get.setHeader("Host", host);
+ get.setHeader("Date", dateHdr);
+ get.setHeader("Authorization", "AWS " + accessKey + ":" + signature);
+
+ try (CloseableHttpResponse resp = http.execute(get)) {
+ final int st = resp.getStatusLine().getStatusCode();
+ final String rb = resp.getEntity() != null
+ ? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8)
+ : "";
+
+ if (st != 200 && st != 204) {
+ throw new CloudRuntimeException("S3 get versioning failed: HTTP " + st + " body=" + rb);
+ }
+
+ final String status = xml.extractTag(rb, "Status");
+ return status != null ? status.trim() : "";
+ }
+ }
+
+ private void setS3BucketVersioningWithVerify(final CloseableHttpClient http,
+ final String scheme,
+ final String host,
+ final String bucketName,
+ final String accessKey,
+ final String secretKey,
+ final String desired) throws Exception {
+ setS3BucketVersioning(http, scheme, host, bucketName, accessKey, secretKey, desired);
+
+ // Verify (best-effort; ECS may be eventually consistent)
+ for (int i = 1; i <= 10; i++) {
+ try {
+ final String got = getS3BucketVersioningStatus(http, scheme, host, bucketName, accessKey, secretKey);
+ if (desired.equalsIgnoreCase(got)) {
+ logger.info("ECS: versioning verify OK for '{}': {}", bucketName, got);
+ return;
+ }
+ logger.warn("ECS: versioning verify mismatch for '{}': desired={} got={} (try {}/10)",
+ bucketName, desired, got, i);
+ } catch (Exception e) {
+ logger.debug("ECS: versioning verify error for '{}': {} (try {}/10)",
+ bucketName, e.getMessage(), i);
+ }
+
+ try {
+ Thread.sleep(500L);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ }
+
+ logger.warn("ECS: versioning verify FAILED for '{}': desired={} (backend may be eventually consistent)",
+ bucketName, desired);
+ }
+
+ // ---------------- Quota ----------------
+
+ @Override
+ public void setBucketQuota(final BucketTO bucket, final long storeId, final long size) {
+ if (size <= 0) {
+ logger.debug("ECS setBucketQuota ignored for {}: non-positive size {}", bucket.getName(), size);
+ return;
+ }
+
+ final Map ds = storeDetailsDao.getDetails(storeId);
+ final EcsCfg cfg = ecsCfgFromDetails(ds, storeId);
+ final String bucketName = bucket.getName();
+
+ try {
+ tokenManager.callWithRetry401(cfg, token -> {
+ try (CloseableHttpClient http = buildHttpClient(cfg.insecure)) {
+ Integer currentGb = null;
+
+ try {
+ final HttpGet get = new HttpGet(cfg.mgmtUrl + "/object/bucket/" + bucketName + "/quota");
+ get.setHeader("X-SDS-AUTH-TOKEN", token);
+
+ try (CloseableHttpResponse r = http.execute(get)) {
+ final int st = r.getStatusLine().getStatusCode();
+ if (st == 401) {
+ throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS get quota got 401");
+ }
+ if (st == 200) {
+ final String xmlBody = r.getEntity() != null
+ ? EntityUtils.toString(r.getEntity(), StandardCharsets.UTF_8)
+ : "";
+ currentGb = xml.parseIntTag(xmlBody, "blockSize");
+ if (currentGb == null) {
+ currentGb = xml.parseIntTag(xmlBody, "notificationSize");
+ }
+ }
+ }
+ } catch (EcsMgmtTokenManager.EcsUnauthorizedException u) {
+ throw u;
+ } catch (Exception e) {
+ logger.debug("ECS get quota for {} failed (non-fatal): {}", bucketName, e.getMessage());
+ }
+
+ if (currentGb != null && size <= currentGb) {
+ logger.info("ECS setBucketQuota noop for '{}': requested {}GB <= current {}GB", bucketName, size, currentGb);
+ return null;
+ }
+
+ final String quotaBody =
+ ""
+ + "" + size + ""
+ + "" + size + ""
+ + "" + cfg.ns + ""
+ + "";
+
+ final HttpPut put = new HttpPut(cfg.mgmtUrl + "/object/bucket/" + bucketName + "/quota");
+ put.setHeader("X-SDS-AUTH-TOKEN", token);
+ put.setHeader("Content-Type", "application/xml");
+ put.setEntity(new StringEntity(quotaBody, StandardCharsets.UTF_8));
+
+ try (CloseableHttpResponse r2 = http.execute(put)) {
+ final int st2 = r2.getStatusLine().getStatusCode();
+ final String rb2 = r2.getEntity() != null
+ ? EntityUtils.toString(r2.getEntity(), StandardCharsets.UTF_8)
+ : "";
+
+ if (st2 == 401) {
+ throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS set quota got 401");
+ }
+ if (st2 != 200 && st2 != 204) {
+ logger.warn("ECS set quota failed for {}: HTTP {} body={}. Ignoring.", bucketName, st2, rb2);
+ return null;
+ }
+ }
+
+ logger.info("ECS quota set for bucket='{}' newQuota={}GB", bucketName, size);
+ return null;
+ }
+ }, this::buildHttpClient);
+ } catch (Exception e) {
+ logger.warn("ECS setBucketQuota encountered error for {}: {} (ignored)", bucketName, e.getMessage());
+ }
+ }
+
+ @Override
+ public Map getAllBucketsUsage(final long storeId) {
+ throw new CloudRuntimeException("Bucket usage aggregation is not implemented via Mgmt API in this plugin.");
+ }
+
+ // ---------------- helpers ----------------
+
+ static final class EcsCfg {
+ final String mgmtUrl;
+ final String saUser;
+ final String saPass;
+ final String ns;
+ final boolean insecure;
+
+ EcsCfg(final String mgmtUrl, final String saUser, final String saPass, final String ns, final boolean insecure) {
+ this.mgmtUrl = mgmtUrl;
+ this.saUser = saUser;
+ this.saPass = saPass;
+ this.ns = ns;
+ this.insecure = insecure;
+ }
+ }
+
+ private EcsCfg ecsCfgFromDetails(final Map ds, final long storeId) {
+ final String mgmtUrl = trimTail(ds.get(EcsConstants.MGMT_URL));
+ final String saUser = ds.get(EcsConstants.SA_USER);
+ final String saPass = ds.get(EcsConstants.SA_PASS);
+ final String ns = StringUtils.isBlank(ds.get(EcsConstants.NAMESPACE)) ? "default" : ds.get(EcsConstants.NAMESPACE);
+ final boolean insecure = "true".equalsIgnoreCase(ds.getOrDefault(EcsConstants.INSECURE, "false"));
+
+ if (StringUtils.isBlank(mgmtUrl) || StringUtils.isBlank(saUser) || StringUtils.isBlank(saPass)) {
+ throw new CloudRuntimeException("ECS: missing mgmt_url/sa_user/sa_password for store id=" + storeId);
+ }
+ return new EcsCfg(mgmtUrl, saUser, saPass, ns, insecure);
+ }
+
+ private String getUserPrefix(final Map ds) {
+ String p = null;
+ if (ds != null) {
+ p = ds.get(EcsConstants.USER_PREFIX);
+ }
+ if (StringUtils.isBlank(p)) {
+ return EcsConstants.DEFAULT_USER_PREFIX;
+ }
+ return p.trim();
+ }
+
+ private static String valueOrNull(final AccountDetailVO d) {
+ if (d == null) {
+ return null;
+ }
+ return d.getValue();
+ }
+
+ private static String trimTail(final String s) {
+ if (s == null) {
+ return null;
+ }
+ if (s.endsWith("/")) {
+ return s.substring(0, s.length() - 1);
+ }
+ return s;
+ }
+
+ private static String normalizeHostOnly(final String hostOrUrl) {
+ if (hostOrUrl == null) {
+ return null;
+ }
+
+ String h = hostOrUrl.trim();
+ if (h.startsWith("http://")) {
+ h = h.substring("http://".length());
+ }
+ if (h.startsWith("https://")) {
+ h = h.substring("https://".length());
+ }
+ while (h.endsWith("/")) {
+ h = h.substring(0, h.length() - 1);
+ }
+ return h;
+ }
+
+ private CloseableHttpClient buildHttpClient(final boolean insecure) {
+ if (!insecure) {
+ return HttpClients.createDefault();
+ }
+ try {
+ final TrustStrategy trustAll = (chain, authType) -> true;
+ final SSLContext sslContext = SSLContextBuilder.create()
+ .loadTrustMaterial(null, trustAll)
+ .build();
+ return HttpClients.custom()
+ .setSSLContext(sslContext)
+ .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE)
+ .build();
+ } catch (Exception e) {
+ throw new CloudRuntimeException("Failed to build insecure HttpClient", e);
+ }
+ }
+
+ // GET /object/user-secret-keys/{username} and parse any existing secrets.
+ private List fetchEcsUserSecrets(final CloseableHttpClient http,
+ final String mgmtUrl,
+ final String token,
+ final String username) throws Exception {
+ final HttpGet get = new HttpGet(mgmtUrl + "/object/user-secret-keys/" + username);
+ get.setHeader("X-SDS-AUTH-TOKEN", token);
+
+ try (CloseableHttpResponse r = http.execute(get)) {
+ final int st = r.getStatusLine().getStatusCode();
+ if (st == 401) {
+ throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS fetch secrets got 401");
+ }
+
+ if (st == 200) {
+ final String xmlBody = r.getEntity() != null
+ ? EntityUtils.toString(r.getEntity(), StandardCharsets.UTF_8)
+ : "";
+ final java.util.ArrayList out = new java.util.ArrayList<>();
+
+ final String s1 = xml.extractTag(xmlBody, "secret_key_1");
+ final String s2 = xml.extractTag(xmlBody, "secret_key_2");
+ final String e1 = xml.extractTag(xmlBody, "secret_key_1_exist");
+ final String e2 = xml.extractTag(xmlBody, "secret_key_2_exist");
+
+ if ("true".equalsIgnoreCase(e1) && !StringUtils.isBlank(s1)) {
+ out.add(s1.trim());
+ }
+ if ("true".equalsIgnoreCase(e2) && !StringUtils.isBlank(s2)) {
+ out.add(s2.trim());
+ }
+
+ return out;
+ }
+
+ return java.util.Collections.emptyList();
+ }
+ }
+
+ private void ensureAccountUserAndSecret(final long accountId,
+ final String username,
+ final String mgmtUrl,
+ final String saUser,
+ final String saPass,
+ final String ns,
+ final boolean insecure) {
+ final String haveAcc = valueOrNull(accountDetailsDao.findDetail(accountId, EcsConstants.AD_KEY_ACCESS));
+ final String haveSec = valueOrNull(accountDetailsDao.findDetail(accountId, EcsConstants.AD_KEY_SECRET));
+
+ final EcsCfg cfg = new EcsCfg(trimTail(mgmtUrl), saUser, saPass, ns, insecure);
+
+ try {
+ tokenManager.callWithRetry401(cfg, token -> {
+ try (CloseableHttpClient http = buildHttpClient(insecure)) {
+ // Ensure/CREATE user (idempotent)
+ final String createUserXml =
+ ""
+ + "" + username + ""
+ + "" + ns + ""
+ + ""
+ + "";
+
+ final HttpPost postUser = new HttpPost(mgmtUrl + "/object/users");
+ postUser.setHeader("X-SDS-AUTH-TOKEN", token);
+ postUser.setHeader("Content-Type", "application/xml");
+ postUser.setEntity(new StringEntity(createUserXml, StandardCharsets.UTF_8));
+
+ try (CloseableHttpResponse r = http.execute(postUser)) {
+ final int st = r.getStatusLine().getStatusCode();
+ final String rb = r.getEntity() != null
+ ? EntityUtils.toString(r.getEntity(), StandardCharsets.UTF_8)
+ : "";
+
+ if (st == 401) {
+ throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS ensure user got 401");
+ }
+
+ if (st == 200 || st == 201) {
+ logger.info("ECS user ensured/created for accountId={} -> {}", accountId, username);
+ } else if (st == 400 && rb != null && rb.contains("already exists")) {
+ logger.info("ECS user {} already exists (idempotent).", username);
+ } else {
+ logger.error("ECS user creation failed: status={} body={}", st, rb);
+ throw new CloudRuntimeException("ECS user creation failed: HTTP " + st);
+ }
+ }
+
+ // If ACS already has key -> do NOT create another.
+ if (!StringUtils.isBlank(haveAcc) && !StringUtils.isBlank(haveSec)) {
+ logger.info("ECS single-key policy: accountId={} already has keys stored in ACS; skipping secret creation.", accountId);
+
+ // Optional reconciliation: if ECS has no secret, push ACS secret
+ try {
+ final List ecsKeys = fetchEcsUserSecrets(http, mgmtUrl, token, username);
+ if (ecsKeys.isEmpty()) {
+ final String skXml =
+ ""
+ + "" + ns + ""
+ + "" + haveSec + ""
+ + "";
+
+ final HttpPost postKey = new HttpPost(mgmtUrl + "/object/user-secret-keys/" + username);
+ postKey.setHeader("X-SDS-AUTH-TOKEN", token);
+ postKey.setHeader("Content-Type", "application/xml");
+ postKey.setEntity(new StringEntity(skXml, StandardCharsets.UTF_8));
+
+ try (CloseableHttpResponse kr = http.execute(postKey)) {
+ final int st = kr.getStatusLine().getStatusCode();
+ final String rb = kr.getEntity() != null
+ ? EntityUtils.toString(kr.getEntity(), StandardCharsets.UTF_8)
+ : "";
+
+ if (st == 401) {
+ throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS reconcile secret got 401");
+ }
+
+ if (st == 200 || st == 201) {
+ logger.info("ECS secret reconciled for user {} (secret taken from ACS).", username);
+ } else if (st == 400 && rb != null && rb.contains("already has") && rb.contains("valid keys")) {
+ logger.info("ECS user {} already has valid secret(s); reconciliation not needed.", username);
+ } else {
+ logger.warn("ECS secret reconcile for {} returned HTTP {} body={} (continuing).", username, st, rb);
+ }
+ }
+ }
+ } catch (EcsMgmtTokenManager.EcsUnauthorizedException u) {
+ throw u;
+ } catch (Exception e) {
+ logger.debug("ECS secret reconcile check skipped for {}: {}", username, e.getMessage());
+ }
+
+ return null;
+ }
+
+ // ACS does NOT have key -> try to ADOPT existing ECS key first
+ try {
+ final List ecsKeys = fetchEcsUserSecrets(http, mgmtUrl, token, username);
+ if (!ecsKeys.isEmpty()) {
+ final String adopt = ecsKeys.get(0);
+
+ if (StringUtils.isBlank(haveAcc)) {
+ accountDetailsDao.addDetail(accountId, EcsConstants.AD_KEY_ACCESS, username, false);
+ }
+ if (StringUtils.isBlank(haveSec)) {
+ accountDetailsDao.addDetail(accountId, EcsConstants.AD_KEY_SECRET, adopt, false);
+ }
+
+ logger.info("Adopted existing ECS secret for user {} into ACS (no new key created).", username);
+ return null;
+ }
+ } catch (EcsMgmtTokenManager.EcsUnauthorizedException u) {
+ throw u;
+ } catch (Exception e) {
+ logger.debug("Failed to fetch existing ECS keys for {} (proceeding to create one): {}", username, e.getMessage());
+ }
+
+ // No ECS key either -> create exactly ONE new secret and store in ACS
+ final String newSecret = java.util.UUID.randomUUID().toString().replace("-", "");
+ final String skXmlCreate =
+ ""
+ + "" + ns + ""
+ + "" + newSecret + ""
+ + "";
+
+ final HttpPost postKey2 = new HttpPost(mgmtUrl + "/object/user-secret-keys/" + username);
+ postKey2.setHeader("X-SDS-AUTH-TOKEN", token);
+ postKey2.setHeader("Content-Type", "application/xml");
+ postKey2.setEntity(new StringEntity(skXmlCreate, StandardCharsets.UTF_8));
+
+ try (CloseableHttpResponse kr2 = http.execute(postKey2)) {
+ final int st = kr2.getStatusLine().getStatusCode();
+ final String rb = kr2.getEntity() != null
+ ? EntityUtils.toString(kr2.getEntity(), StandardCharsets.UTF_8)
+ : "";
+
+ if (st == 401) {
+ throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS create secret got 401");
+ }
+
+ if (st != 200 && st != 201) {
+ if (st == 400 && rb != null && rb.contains("already has") && rb.contains("valid keys")) {
+ final List ecsKeys = fetchEcsUserSecrets(http, mgmtUrl, token, username);
+ if (!ecsKeys.isEmpty()) {
+ final String adopt = ecsKeys.get(0);
+
+ if (StringUtils.isBlank(haveAcc)) {
+ accountDetailsDao.addDetail(accountId, EcsConstants.AD_KEY_ACCESS, username, false);
+ }
+ if (StringUtils.isBlank(haveSec)) {
+ accountDetailsDao.addDetail(accountId, EcsConstants.AD_KEY_SECRET, adopt, false);
+ }
+
+ logger.info("Race: ECS already has key(s). Adopted existing secret for {} into ACS.", username);
+ return null;
+ }
+ }
+
+ logger.error("ECS create secret-key failed for {}: status={} body={}", username, st, rb);
+ throw new CloudRuntimeException("ECS secret-key creation failed: HTTP " + st);
+ }
+ }
+
+ if (StringUtils.isBlank(haveAcc)) {
+ accountDetailsDao.addDetail(accountId, EcsConstants.AD_KEY_ACCESS, username, false);
+ }
+ if (StringUtils.isBlank(haveSec)) {
+ accountDetailsDao.addDetail(accountId, EcsConstants.AD_KEY_SECRET, newSecret, false);
+ }
+
+ logger.info("ECS secret key created and stored for user={} (accountId={})", username, accountId);
+ return null;
+ }
+ }, this::buildHttpClient);
+ } catch (CloudRuntimeException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new CloudRuntimeException("Failed to ensure/reconcile ECS user/secret: " + e.getMessage(), e);
+ }
+ }
+
+ // ---------- Endpoint resolving for S3 (from s3_host) ----------
+ private static final class S3Endpoint {
+ final String scheme; // "http" or "https"
+ final String host; // hostname only
+
+ S3Endpoint(final String scheme, final String host) {
+ this.scheme = scheme;
+ this.host = host;
+ }
+ }
+
+ private S3Endpoint resolveS3Endpoint(final Map ds, final long storeId) {
+ String host = normalizeHostOnly(ds.get(EcsConstants.S3_HOST)); // accept host or URL from UI
+ final String scheme = "https";
+
+ if (StringUtils.isBlank(host)) {
+ host = normalizeHostOnly(ds.get("host"));
+ }
+
+ return new S3Endpoint(scheme, host);
+ }
+
+ private String resolveS3HostForUI(final long storeId, final Map ds) {
+ String host = normalizeHostOnly(ds.get(EcsConstants.S3_HOST));
+
+ if (StringUtils.isBlank(host)) {
+ host = normalizeHostOnly(ds.get("host"));
+ }
+
+ return host;
+ }
+
+ // ---------- Mgmt owner → accountId fallback ----------
+ private Long resolveAccountIdViaMgmt(final String bucketName, final Map ds, final boolean insecure) {
+ final String mgmtUrl = trimTail(ds.get(EcsConstants.MGMT_URL));
+ final String saUser = ds.get(EcsConstants.SA_USER);
+ final String saPass = ds.get(EcsConstants.SA_PASS);
+
+ if (StringUtils.isBlank(mgmtUrl) || StringUtils.isBlank(saUser) || StringUtils.isBlank(saPass)) {
+ return null;
+ }
+
+ final EcsCfg cfg = new EcsCfg(
+ mgmtUrl,
+ saUser,
+ saPass,
+ StringUtils.isBlank(ds.get(EcsConstants.NAMESPACE)) ? "default" : ds.get(EcsConstants.NAMESPACE),
+ insecure);
+
+ final String prefix = getUserPrefix(ds);
+
+ try {
+ return tokenManager.callWithRetry401(cfg, token -> {
+ try (CloseableHttpClient http = buildHttpClient(insecure)) {
+ final String owner = fetchBucketOwnerViaMgmt(http, mgmtUrl, token, bucketName);
+
+ if (!StringUtils.isBlank(owner) && !StringUtils.isBlank(prefix) && owner.startsWith(prefix) && owner.length() > prefix.length()) {
+ final String uuid = owner.substring(prefix.length());
+ try {
+ final Account acct = accountDao.findByUuid(uuid);
+ if (acct != null) {
+ return acct.getId();
+ }
+ } catch (Throwable ignore) {
+ }
+ }
+
+ return null;
+ }
+ }, this::buildHttpClient);
+ } catch (Exception e) {
+ logger.debug("ECS: resolveAccountIdViaMgmt '{}' failed: {}", bucketName, e.getMessage());
+ return null;
+ }
+ }
+
+ private String fetchBucketOwnerViaMgmt(final CloseableHttpClient http, final String mgmtUrl, final String token, final String bucketName) throws Exception {
+ final HttpGet get = new HttpGet(mgmtUrl + "/object/bucket/" + bucketName);
+ get.setHeader("X-SDS-AUTH-TOKEN", token);
+
+ try (CloseableHttpResponse r = http.execute(get)) {
+ final int st = r.getStatusLine().getStatusCode();
+
+ if (st == 401) {
+ throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS fetch bucket owner got 401");
+ }
+
+ if (st == 200) {
+ final String xmlBody = r.getEntity() != null
+ ? EntityUtils.toString(r.getEntity(), StandardCharsets.UTF_8)
+ : "";
+ final String owner = xml.extractTag(xmlBody, "owner");
+ if (!StringUtils.isBlank(owner)) {
+ return owner.trim();
+ }
+ }
+
+ return null;
+ }
+ }
+
+ // ---------- Reflection helper (only where needed) ----------
+ private static long getLongFromGetter(final Object o, final String getter, final long defVal) {
+ if (o == null) {
+ return defVal;
+ }
+ try {
+ final Object v = o.getClass().getMethod(getter).invoke(o);
+ if (v instanceof Number) {
+ return ((Number) v).longValue();
+ }
+ if (v instanceof String && !((String) v).isEmpty()) {
+ return Long.parseLong((String) v);
+ }
+ } catch (Throwable ignore) {
+ }
+ return defVal;
+ }
+
+ private BucketVO resolveBucketVO(final BucketTO bucket) {
+ if (bucket == null) return null;
+
+ long id = getLongFromGetter(bucket, "getId", -1L);
+ if (id > 0) {
+ return bucketDao.findById(id);
+ }
+ return null;
+ }
+
+ private static String base64Md5(final byte[] data) throws Exception {
+ final java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
+ final byte[] digest = md.digest(data);
+ return Base64.getEncoder().encodeToString(digest);
+ }
+
+ private static String hmacSha1Base64(final String data, final String key) throws Exception {
+ final javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA1");
+ final javax.crypto.spec.SecretKeySpec sk =
+ new javax.crypto.spec.SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA1");
+ mac.init(sk);
+ final byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
+ return Base64.getEncoder().encodeToString(raw);
+ }
+
+ private static String rfc1123Now() {
+ final java.time.format.DateTimeFormatter fmt = java.time.format.DateTimeFormatter
+ .ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US)
+ .withZone(java.time.ZoneOffset.UTC);
+ return fmt.format(java.time.Instant.now());
+ }
+
+ // GET /policy raw body; returns "" if none (200 with empty/{} or 204/404).
+ private String getBucketPolicyRaw(final String url, final String token, final boolean insecure) {
+ try (CloseableHttpClient http = buildHttpClient(insecure)) {
+ final HttpGet get = new HttpGet(url);
+ get.setHeader("X-SDS-AUTH-TOKEN", token);
+
+ try (CloseableHttpResponse resp = http.execute(get)) {
+ final int st = resp.getStatusLine().getStatusCode();
+ if (st == 401) {
+ throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS getBucketPolicyRaw got 401");
+ }
+
+ final String body = resp.getEntity() == null ? ""
+ : EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8).trim();
+
+ if (st == 200) {
+ return "{}".equals(body) ? "" : body;
+ }
+ if (st == 204 || st == 404 || ((st / 100) == 2 && body.isEmpty())) {
+ return "";
+ }
+
+ throw new CloudRuntimeException("ECS getBucketPolicy failed: HTTP " + st + " body=" + body);
+ }
+ } catch (EcsMgmtTokenManager.EcsUnauthorizedException u) {
+ throw u;
+ } catch (Exception e) {
+ throw new CloudRuntimeException("ECS getBucketPolicy error: " + e.getMessage(), e);
+ }
+ }
+
+ // PUT /policy with JSON.
+ private void putBucketPolicy(final String url, final String token, final String policyJson, final boolean insecure) {
+ try (CloseableHttpClient http = buildHttpClient(insecure)) {
+ final HttpPut put = new HttpPut(url);
+ put.setHeader("X-SDS-AUTH-TOKEN", token);
+ put.setHeader("Content-Type", "application/json");
+ put.setEntity(new StringEntity(policyJson, StandardCharsets.UTF_8));
+
+ try (CloseableHttpResponse resp = http.execute(put)) {
+ final int st = resp.getStatusLine().getStatusCode();
+ if (st == 401) {
+ throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS putBucketPolicy got 401");
+ }
+ if (st == 200 || st == 204) {
+ return;
+ }
+
+ final String body = resp.getEntity() == null ? ""
+ : EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8);
+
+ throw new CloudRuntimeException("ECS setBucketPolicy failed: HTTP " + st + " body=" + body);
+ }
+ } catch (EcsMgmtTokenManager.EcsUnauthorizedException u) {
+ throw u;
+ } catch (Exception e) {
+ throw new CloudRuntimeException("ECS setBucketPolicy error: " + e.getMessage(), e);
+ }
+ }
+
+ // DELETE /policy to make bucket private.
+ private void deleteBucketPolicyHttp(final String url, final String token, final boolean insecure) {
+ try (CloseableHttpClient http = buildHttpClient(insecure)) {
+ final HttpDelete del = new HttpDelete(url);
+ del.setHeader("X-SDS-AUTH-TOKEN", token);
+
+ try (CloseableHttpResponse resp = http.execute(del)) {
+ final int st = resp.getStatusLine().getStatusCode();
+ if (st == 401) {
+ throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS deleteBucketPolicyHttp got 401");
+ }
+ if (st == 200 || st == 204) {
+ return;
+ }
+
+ final String body = resp.getEntity() == null ? ""
+ : EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8);
+
+ throw new CloudRuntimeException("ECS deleteBucketPolicy failed: HTTP " + st + " body=" + body);
+ }
+ } catch (EcsMgmtTokenManager.EcsUnauthorizedException u) {
+ throw u;
+ } catch (Exception e) {
+ throw new CloudRuntimeException("ECS deleteBucketPolicy error: " + e.getMessage(), e);
+ }
+ }
+
+ // Check if a bucket exists on ECS via Mgmt API /object/bucket/{name}/info?namespace=...
+ private boolean ecsBucketExists(final String bucketName, final Map ds) {
+ final String mgmtUrl = trimTail(ds.get(EcsConstants.MGMT_URL));
+ final String saUser = ds.get(EcsConstants.SA_USER);
+ final String saPass = ds.get(EcsConstants.SA_PASS);
+ final String ns = StringUtils.isBlank(ds.get(EcsConstants.NAMESPACE)) ? "default" : ds.get(EcsConstants.NAMESPACE);
+ final boolean insecure = "true".equalsIgnoreCase(ds.getOrDefault(EcsConstants.INSECURE, "false"));
+
+ if (StringUtils.isBlank(bucketName)) {
+ logger.warn("ecsBucketExists: bucket name is blank; treating as non-existent.");
+ return false;
+ }
+
+ if (StringUtils.isBlank(mgmtUrl) || StringUtils.isBlank(saUser) || StringUtils.isBlank(saPass)) {
+ logger.warn("ecsBucketExists('{}'): missing mgmt_url/sa_user/sa_password; assuming bucket exists.", bucketName);
+ return true;
+ }
+
+ final EcsCfg cfg = new EcsCfg(mgmtUrl, saUser, saPass, ns, insecure);
+
+ try {
+ return tokenManager.callWithRetry401(cfg, token -> {
+ try (CloseableHttpClient http = buildHttpClient(insecure)) {
+ final String url = mgmtUrl
+ + "/object/bucket/"
+ + java.net.URLEncoder.encode(bucketName, java.nio.charset.StandardCharsets.UTF_8.name())
+ + "/info?namespace="
+ + java.net.URLEncoder.encode(ns, java.nio.charset.StandardCharsets.UTF_8.name());
+
+ logger.debug("ecsBucketExists('{}'): GET {}", bucketName, url);
+
+ final HttpGet get = new HttpGet(url);
+ get.setHeader("X-SDS-AUTH-TOKEN", token);
+
+ try (CloseableHttpResponse resp = http.execute(get)) {
+ final int st = resp.getStatusLine().getStatusCode();
+ final String body = resp.getEntity() != null
+ ? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8)
+ : "";
+
+ if (st == 401) {
+ throw new EcsMgmtTokenManager.EcsUnauthorizedException("ecsBucketExists got 401");
+ }
+
+ if (st == 200) {
+ return true;
+ }
+ if (st == 404) {
+ return false;
+ }
+
+ if (st == 400) {
+ final String errCode = xml.extractTag(body, "code");
+ final String errDetail = xml.extractTag(body, "details");
+ final String errDesc = xml.extractTag(body, "description");
+
+ final String lowerBody = body == null ? "" : body.toLowerCase(Locale.ROOT);
+ final String lowerDetail = errDetail == null ? "" : errDetail.toLowerCase(Locale.ROOT);
+ final String lowerDesc = errDesc == null ? "" : errDesc.toLowerCase(Locale.ROOT);
+
+ final boolean notFoundByCode = "1004".equals(errCode);
+ final boolean notFoundByText =
+ lowerBody.contains("unable to find entity with the given id")
+ || lowerDetail.contains("unable to find entity with the given id")
+ || lowerDesc.contains("unable to find entity with the given id")
+ || lowerDesc.contains("request parameter cannot be found");
+
+ if (notFoundByCode || notFoundByText) {
+ return false;
+ }
+ }
+
+ logger.warn("ecsBucketExists('{}'): unexpected HTTP {} body={}; treating as EXISTS.", bucketName, st, body);
+ return true;
+ }
+ }
+ }, this::buildHttpClient);
+ } catch (Exception e) {
+ logger.warn("ecsBucketExists('{}') failed: {}. Conservatively treating as EXISTS.", bucketName, e.getMessage());
+ return true;
+ }
+ }
+}
diff --git a/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsXmlParser.java b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsXmlParser.java
new file mode 100644
index 000000000000..3db030679819
--- /dev/null
+++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsXmlParser.java
@@ -0,0 +1,71 @@
+package org.apache.cloudstack.storage.datastore.driver;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+public class EcsXmlParser {
+
+ public Integer parseIntTag(final String xml, final String tag) {
+ String v = extractTag(xml, tag);
+ if (v == null) return null;
+ try { return Integer.parseInt(v.trim()); } catch (NumberFormatException ignore) { return null; }
+ }
+
+ public String extractTag(final String xml, final String tag) {
+ if (xml == null) return null;
+ final String open = "<" + tag + ">";
+ final String close = "" + tag + ">";
+ int i = xml.indexOf(open);
+ if (i < 0) return null;
+ int j = xml.indexOf(close, i + open.length());
+ if (j < 0) return null;
+ return xml.substring(i + open.length(), j).trim();
+ }
+
+ public List extractAllTags(final String xml, final String tag) {
+ final List out = new ArrayList<>();
+ if (xml == null) return out;
+
+ final String open = "<" + tag + ">";
+ final String close = "" + tag + ">";
+
+ int from = 0;
+ while (true) {
+ int i = xml.indexOf(open, from);
+ if (i < 0) break;
+ int j = xml.indexOf(close, i + open.length());
+ if (j < 0) break;
+ out.add(xml.substring(i + open.length(), j).trim());
+ from = j + close.length();
+ }
+ return out;
+ }
+
+ public void extractKeysFromListBucketXml(final String xml, final List keys) {
+ if (xml == null) return;
+ final String contentsOpen = "";
+ final String contentsClose = "";
+ int from = 0;
+ while (true) {
+ int i = xml.indexOf(contentsOpen, from);
+ if (i < 0) break;
+ int j = xml.indexOf(contentsClose, i + contentsOpen.length());
+ if (j < 0) break;
+ String block = xml.substring(i, j + contentsClose.length());
+ String key = extractTag(block, "Key");
+ if (key != null && !key.isEmpty()) keys.add(key.trim());
+ from = j + contentsClose.length();
+ }
+ }
+
+ public boolean looksLikeBucketAlreadyExists400(final String respBody) {
+ final String lb = respBody == null ? "" : respBody.toLowerCase(Locale.ROOT);
+ return lb.contains("already exist")
+ || lb.contains("already_exists")
+ || lb.contains("already-exists")
+ || lb.contains("name already in use")
+ || lb.contains("bucket exists")
+ || lb.contains("duplicate");
+ }
+}
diff --git a/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/EcsObjectStoreLifeCycleImpl.java b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/EcsObjectStoreLifeCycleImpl.java
new file mode 100644
index 000000000000..213b73a06d30
--- /dev/null
+++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/EcsObjectStoreLifeCycleImpl.java
@@ -0,0 +1,321 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.cloudstack.storage.datastore.lifecycle;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.inject.Inject;
+import javax.net.ssl.SSLContext;
+
+import org.apache.cloudstack.engine.subsystem.api.storage.ClusterScope;
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
+import org.apache.cloudstack.engine.subsystem.api.storage.HostScope;
+import org.apache.cloudstack.engine.subsystem.api.storage.ZoneScope;
+import org.apache.cloudstack.storage.datastore.db.ObjectStoreVO;
+import org.apache.cloudstack.storage.datastore.driver.EcsConstants;
+import org.apache.cloudstack.storage.object.datastore.ObjectStoreHelper;
+import org.apache.cloudstack.storage.object.datastore.ObjectStoreProviderManager;
+import org.apache.cloudstack.storage.object.store.lifecycle.ObjectStoreLifeCycle;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet; // change to POST if ECS needs it
+import org.apache.http.conn.ssl.NoopHostnameVerifier;
+import org.apache.http.impl.auth.BasicScheme;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.ssl.SSLContextBuilder;
+import org.apache.http.ssl.TrustStrategy;
+
+import com.cloud.agent.api.StoragePoolInfo;
+import com.cloud.hypervisor.Hypervisor.HypervisorType;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+public class EcsObjectStoreLifeCycleImpl implements ObjectStoreLifeCycle {
+
+ private static final Logger LOG = LogManager.getLogger(EcsObjectStoreLifeCycleImpl.class);
+
+ private static final String PROVIDER_NAME = "ECS";
+
+ @Inject
+ ObjectStoreHelper objectStoreHelper;
+
+ @Inject
+ ObjectStoreProviderManager objectStoreMgr;
+
+ public EcsObjectStoreLifeCycleImpl() {
+ }
+
+ @Override
+ public DataStore initialize(final Map dsInfos) {
+ requireInjected();
+
+ final String url = getString(dsInfos, "url", true);
+ final String name = getString(dsInfos, "name", true);
+ final Long size = getLong(dsInfos, "size"); // optional
+ final String providerName = getProviderName(dsInfos);
+
+ final Map details = getDetails(dsInfos); // typed map, no unchecked cast
+
+ final EcsConfig cfg = verifyAndNormalize(details);
+
+ LOG.info("ECS initialize: provider='{}', name='{}', url='{}', mgmt_url='{}', insecure={}, s3_host='{}', namespace='{}'",
+ providerName, name, url, cfg.mgmtUrl, cfg.insecure,
+ details.get(EcsConstants.S3_HOST), details.get(EcsConstants.NAMESPACE));
+
+ // Try ECS login up-front so we fail fast on bad config
+ loginAndGetToken(cfg.mgmtUrl, cfg.saUser, cfg.saPass, cfg.insecure);
+
+ // Put “canonical” values back into details (so DB keeps what we validated)
+ applyCanonicalDetails(details, cfg);
+
+ final Map objectStoreParameters = buildObjectStoreParams(name, url, size, providerName);
+
+ try {
+ LOG.info("ECS: creating ObjectStore in DB: name='{}', provider='{}', url='{}'",
+ name, providerName, url);
+
+ final ObjectStoreVO objectStore = objectStoreHelper.createObjectStore(objectStoreParameters, details);
+ if (objectStore == null) {
+ throw new CloudRuntimeException("ECS: createObjectStore returned null");
+ }
+
+ final DataStore store = objectStoreMgr.getObjectStore(objectStore.getId());
+ if (store == null) {
+ throw new CloudRuntimeException("ECS: getObjectStore returned null for id=" + objectStore.getId());
+ }
+
+ LOG.info("ECS: object store created: id={}, name='{}'", objectStore.getId(), name);
+ return store;
+ } catch (RuntimeException e) {
+ final String msg = "ECS: failed to persist object store '" + name + "': " + safeMsg(e);
+ LOG.error(msg, e);
+ throw new CloudRuntimeException(msg, e);
+ }
+ }
+
+ @Override
+ public boolean attachCluster(final DataStore store, final ClusterScope scope) {
+ return false;
+ }
+
+ @Override
+ public boolean attachHost(final DataStore store, final HostScope scope, final StoragePoolInfo existingInfo) {
+ return false;
+ }
+
+ @Override
+ public boolean attachZone(final DataStore dataStore, final ZoneScope scope, final HypervisorType hypervisorType) {
+ return false;
+ }
+
+ @Override
+ public boolean maintain(final DataStore store) {
+ return false;
+ }
+
+ @Override
+ public boolean cancelMaintain(final DataStore store) {
+ return false;
+ }
+
+ @Override
+ public boolean deleteDataStore(final DataStore store) {
+ return false;
+ }
+
+ @Override
+ public boolean migrateToObjectStore(final DataStore store) {
+ return false;
+ }
+
+ // ---------- helpers ----------
+
+ private static final class EcsConfig {
+ final String mgmtUrl;
+ final String saUser;
+ final String saPass;
+ final boolean insecure;
+
+ private EcsConfig(final String mgmtUrl, final String saUser, final String saPass, final boolean insecure) {
+ this.mgmtUrl = mgmtUrl;
+ this.saUser = saUser;
+ this.saPass = saPass;
+ this.insecure = insecure;
+ }
+ }
+
+ private void requireInjected() {
+ if (objectStoreHelper == null) {
+ throw new CloudRuntimeException("ECS: ObjectStoreHelper is not injected");
+ }
+ if (objectStoreMgr == null) {
+ throw new CloudRuntimeException("ECS: ObjectStoreProviderManager is not injected");
+ }
+ }
+
+ private static String getString(final Map dsInfos, final String key, final boolean required) {
+ final Object v = dsInfos.get(key);
+ final String s = (v == null) ? null : v.toString().trim();
+ if (required && (s == null || s.isEmpty())) {
+ throw new CloudRuntimeException("ECS: missing required parameter '" + key + "'");
+ }
+ return s;
+ }
+
+ private static Long getLong(final Map dsInfos, final String key) {
+ final Object v = dsInfos.get(key);
+ if (v == null) {
+ return null;
+ }
+ if (v instanceof Number) {
+ return ((Number) v).longValue();
+ }
+ try {
+ return Long.parseLong(v.toString().trim());
+ } catch (Exception e) {
+ throw new CloudRuntimeException("ECS: invalid long for '" + key + "': " + v);
+ }
+ }
+
+ private static String getProviderName(final Map dsInfos) {
+ final String p = getString(dsInfos, "providerName", false);
+ return (p == null || p.isEmpty()) ? PROVIDER_NAME : p;
+ }
+
+ private static Map getDetails(final Map dsInfos) {
+ final Object v = dsInfos.get("details");
+ if (!(v instanceof Map)) {
+ throw new CloudRuntimeException("ECS: details map is missing");
+ }
+
+ final Map, ?> raw = (Map, ?>) v;
+ final Map out = new HashMap<>();
+ for (Map.Entry, ?> e : raw.entrySet()) {
+ if (e.getKey() == null) {
+ continue;
+ }
+ final String k = e.getKey().toString();
+ final String val = e.getValue() == null ? null : e.getValue().toString();
+ out.put(k, val);
+ }
+ return out;
+ }
+
+ private static EcsConfig verifyAndNormalize(final Map details) {
+ final String mgmtUrl = trim(details.get(EcsConstants.MGMT_URL));
+ final String saUser = safe(details.get(EcsConstants.SA_USER));
+ final String saPass = safe(details.get(EcsConstants.SA_PASS));
+ final boolean insecure = Boolean.parseBoolean(details.getOrDefault(EcsConstants.INSECURE, "false"));
+
+ verifyRequiredDetail(EcsConstants.MGMT_URL, mgmtUrl);
+ verifyRequiredDetail(EcsConstants.SA_USER, saUser);
+ verifyRequiredDetail(EcsConstants.SA_PASS, saPass);
+
+ return new EcsConfig(mgmtUrl, saUser, saPass, insecure);
+ }
+
+ private static void verifyRequiredDetail(final String key, final String value) {
+ if (value == null || value.trim().isEmpty()) {
+ throw new CloudRuntimeException("ECS: missing required detail '" + key + "'");
+ }
+ }
+
+ private static void applyCanonicalDetails(final Map details, final EcsConfig cfg) {
+ details.put(EcsConstants.MGMT_URL, cfg.mgmtUrl);
+ details.put(EcsConstants.SA_USER, cfg.saUser);
+ details.put(EcsConstants.SA_PASS, cfg.saPass);
+ details.put(EcsConstants.INSECURE, Boolean.toString(cfg.insecure));
+ // keep any optional keys already present (S3_HOST, NAMESPACE, etc.)
+ }
+
+ private static Map buildObjectStoreParams(final String name,
+ final String url,
+ final Long size,
+ final String providerName) {
+ final Map p = new HashMap<>();
+ p.put("name", name);
+ p.put("url", url);
+ p.put("size", size);
+ p.put("providerName", providerName);
+ return p;
+ }
+
+ private static String safe(final String v) {
+ return v == null ? "" : v.trim();
+ }
+
+ private static String trim(final String v) {
+ if (v == null) {
+ return null;
+ }
+ final String s = v.trim();
+ return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
+ }
+
+ private static String safeMsg(final Throwable t) {
+ if (t == null) {
+ return "unknown";
+ }
+ final String m = t.getMessage();
+ return (m == null || m.isEmpty()) ? t.getClass().getSimpleName() : m;
+ }
+
+ private CloseableHttpClient buildHttpClient(final boolean insecure) {
+ if (!insecure) {
+ return HttpClients.createDefault();
+ }
+ try {
+ final TrustStrategy trustAll = (chain, authType) -> true;
+ final SSLContext sslContext = SSLContextBuilder.create()
+ .loadTrustMaterial(null, trustAll)
+ .build();
+ return HttpClients.custom()
+ .setSSLContext(sslContext)
+ .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE)
+ .build();
+ } catch (Exception e) {
+ throw new CloudRuntimeException("ECS: failed to build HttpClient", e);
+ }
+ }
+
+ private String loginAndGetToken(final String mgmtUrl, final String user, final String pass, final boolean insecure) {
+ try (CloseableHttpClient http = buildHttpClient(insecure)) {
+ final HttpGet get = new HttpGet(mgmtUrl + "/login");
+ get.addHeader(new BasicScheme().authenticate(
+ new UsernamePasswordCredentials(user, pass), get, null));
+ try (CloseableHttpResponse resp = http.execute(get)) {
+ final int status = resp.getStatusLine().getStatusCode();
+ if (status != 200 && status != 201) {
+ throw new CloudRuntimeException("ECS /login failed: HTTP " + status);
+ }
+ if (resp.getFirstHeader("X-SDS-AUTH-TOKEN") == null) {
+ throw new CloudRuntimeException("ECS /login missing X-SDS-AUTH-TOKEN");
+ }
+ return resp.getFirstHeader("X-SDS-AUTH-TOKEN").getValue();
+ }
+ } catch (Exception e) {
+ final String msg = "ECS: management login error: " + safeMsg(e);
+ LOG.error(msg, e);
+ throw new CloudRuntimeException(msg, e);
+ }
+ }
+}
diff --git a/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/provider/EcsObjectStoreProviderImpl.java b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/provider/EcsObjectStoreProviderImpl.java
new file mode 100644
index 000000000000..4661ed0ebe17
--- /dev/null
+++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/provider/EcsObjectStoreProviderImpl.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.cloudstack.storage.datastore.provider;
+
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import javax.inject.Inject;
+
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreDriver;
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreLifeCycle;
+import org.apache.cloudstack.engine.subsystem.api.storage.HypervisorHostListener;
+import org.apache.cloudstack.engine.subsystem.api.storage.ObjectStoreProvider;
+import org.apache.cloudstack.storage.datastore.driver.EcsObjectStoreDriverImpl;
+import org.apache.cloudstack.storage.datastore.lifecycle.EcsObjectStoreLifeCycleImpl;
+import org.apache.cloudstack.storage.object.ObjectStoreDriver;
+import org.apache.cloudstack.storage.object.datastore.ObjectStoreHelper;
+import org.apache.cloudstack.storage.object.datastore.ObjectStoreProviderManager;
+import org.apache.cloudstack.storage.object.store.lifecycle.ObjectStoreLifeCycle;
+import org.springframework.stereotype.Component;
+
+import com.cloud.utils.component.ComponentContext;
+
+@Component
+public class EcsObjectStoreProviderImpl implements ObjectStoreProvider {
+
+ @Inject
+ ObjectStoreProviderManager storeMgr;
+
+ @Inject
+ ObjectStoreHelper helper;
+
+ private final String providerName = "ECS";
+
+ protected ObjectStoreLifeCycle lifeCycle;
+ protected ObjectStoreDriver driver;
+
+ @Override
+ public String getName() {
+ return providerName;
+ }
+
+ @Override
+ public boolean configure(Map params) {
+ // Follow Ceph provider pattern
+ lifeCycle = ComponentContext.inject(EcsObjectStoreLifeCycleImpl.class);
+ driver = ComponentContext.inject(EcsObjectStoreDriverImpl.class);
+ storeMgr.registerDriver(getName(), driver);
+ return true;
+ }
+
+ @Override
+ public DataStoreLifeCycle getDataStoreLifeCycle() {
+ return lifeCycle;
+ }
+
+ @Override
+ public DataStoreDriver getDataStoreDriver() {
+ return driver;
+ }
+
+ @Override
+ public HypervisorHostListener getHostListener() {
+ return null;
+ }
+
+ @Override
+ public Set getTypes() {
+ Set types = new HashSet<>();
+ types.add(DataStoreProviderType.OBJECT);
+ return types;
+ }
+}
diff --git a/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/module.properties b/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/module.properties
new file mode 100644
index 000000000000..46e642a9ec1b
--- /dev/null
+++ b/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/module.properties
@@ -0,0 +1,18 @@
+# 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.
+name=storage-object-ecs
+parent=storage
diff --git a/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/spring-storage-object-ecs-context.xml b/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/spring-storage-object-ecs-context.xml
new file mode 100644
index 000000000000..e7345e43455b
--- /dev/null
+++ b/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/spring-storage-object-ecs-context.xml
@@ -0,0 +1,31 @@
+
+
+
+
diff --git a/ui/src/views/infra/AddObjectStorage.vue b/ui/src/views/infra/AddObjectStorage.vue
index 5410a9b9502f..935c413d5cc5 100644
--- a/ui/src/views/infra/AddObjectStorage.vue
+++ b/ui/src/views/infra/AddObjectStorage.vue
@@ -31,6 +31,7 @@
+
@@ -51,8 +52,8 @@
+
-
@@ -67,16 +68,82 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Allow insecure HTTPS (set insecure=true)
+
+
+
+
+
+
+
+