From 6019139fefa76603506b256459f8c3edfc205f98 Mon Sep 17 00:00:00 2001 From: MHK Date: Sun, 28 Dec 2025 14:19:31 +0300 Subject: [PATCH] Update ECS Plugin: address reviewer comments for ECS Plugin --- .../datastore/driver/EcsConstants.java | 19 + .../datastore/driver/EcsMgmtTokenManager.java | 142 ++ .../driver/EcsObjectStoreDriverImpl.java | 1334 +++++++++-------- .../datastore/driver/EcsXmlParser.java | 71 + .../EcsObjectStoreLifeCycleImpl.java | 44 +- ui/src/views/infra/AddObjectStorage.vue | 39 +- 6 files changed, 946 insertions(+), 703 deletions(-) create mode 100644 plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsConstants.java create mode 100644 plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsMgmtTokenManager.java create mode 100644 plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsXmlParser.java 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 00000000000..b2f4bab2f35 --- /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 00000000000..6a645d447d0 --- /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 index 9cbe8cac1a7..03515638bde 100644 --- 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 @@ -23,13 +23,18 @@ import java.util.Base64; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; import javax.inject.Inject; import javax.net.ssl.SSLContext; -import org.apache.http.auth.UsernamePasswordCredentials; +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; @@ -37,95 +42,45 @@ 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.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 org.apache.http.util.EntityUtils; -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.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - 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.AccountDetailsDao; 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 { - private static final Logger logger = LogManager.getLogger(EcsObjectStoreDriverImpl.class); - - // Object store details keys - private static final String MGMT_URL = "mgmt_url"; // e.g. https://ecs-api.example.com - private static final String SA_USER = "sa_user"; // service account user - private static final String SA_PASS = "sa_password"; // service account password - private static final String NAMESPACE = "namespace"; // e.g. cloudstack - private static final String INSECURE = "insecure"; // "true" to ignore TLS cert/host - private static final String S3_HOST = "s3_host"; // S3 endpoint host (or URL if UI provides it) - - // Per-account keys - private static final String AD_KEY_ACCESS = "ecs.accesskey"; - private static final String AD_KEY_SECRET = "ecs.secretkey"; - - // ---- ECS token caching ---- - private static final long DEFAULT_TOKEN_MAX_AGE_SEC = 300; // fallback if header missing - private static final long EXPIRY_SKEW_SEC = 30; // refresh early - private static final ConcurrentHashMap TOKEN_CACHE = new ConcurrentHashMap<>(); - private static final ConcurrentHashMap TOKEN_LOCKS = new ConcurrentHashMap<>(); - - 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; - } - } - - private static final class EcsUnauthorizedException extends RuntimeException { - EcsUnauthorizedException(final String msg) { super(msg); } - } - - @FunctionalInterface - private interface WithToken { T run(String token) throws Exception; } + // ---- Injected dependencies ---- @Inject private AccountDao accountDao; @Inject private AccountDetailsDao accountDetailsDao; @Inject private BucketDao bucketDao; @Inject private ObjectStoreDetailsDao storeDetailsDao; - public EcsObjectStoreDriverImpl() { } + 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) { @@ -137,68 +92,88 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { @Override public Bucket createBucket(final Bucket bucket, final boolean objectLock) { final long storeId = bucket.getObjectStoreId(); - final String name = bucket.getName(); + final String name = bucket.getName(); if (objectLock) { - throw new CloudRuntimeException("Dell ECS doesn't support this feature: object locking"); + throw new InvalidParameterValueException("Dell ECS doesn't support this feature: object locking"); } final Map ds = storeDetailsDao.getDetails(storeId); final EcsCfg cfg = ecsCfgFromDetails(ds, storeId); - // Resolve owner username for this bucket 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 = "cs-" + acct.getUuid(); + + 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) + // Quota from UI (INT GB). Bucket.getQuota may be Integer; Bucket.getSize may be Long. Integer quotaGb = null; try { - quotaGb = safeIntFromGetter(bucket, "getQuota"); - if (quotaGb == null) quotaGb = safeIntFromGetter(bucket, "getSize"); - } catch (Throwable ignored) { } + quotaGb = bucket.getQuota(); + } catch (Throwable ignored) { + } - final int blockSizeGb = quotaGb != null && quotaGb > 0 ? quotaGb : 2; - final int notifSizeGb = quotaGb != null && quotaGb > 0 ? quotaGb : 1; + if (quotaGb == null) { + try { + final Long sz = bucket.getSize(); + if (sz != null) { + quotaGb = sz.intValue(); + } + } catch (Throwable ignored) { + } + } - // Encryption flag from request/VO best-effort - boolean encryptionEnabled = - getBooleanFlagLoose(bucket, "getEncryption", "isEncryption", false) || - getBooleanFlagLoose(bucket, "getEncryptionEnabled", "isEncryptionEnabled", false); + final int blockSizeGb; + final int notifSizeGb; + if (quotaGb != null && quotaGb > 0) { + blockSizeGb = quotaGb; + notifSizeGb = quotaGb; + } else { + blockSizeGb = 2; + notifSizeGb = 1; + } - if (!encryptionEnabled && vo != null) { - encryptionEnabled = - getBooleanFlagLoose(vo, "getEncryption", "isEncryption", false) || - getBooleanFlagLoose(vo, "getEncryptionEnabled", "isEncryptionEnabled", false); + // 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") + "" + - ""; + "" + + "" + blockSizeGb + "" + + "" + notifSizeGb + "" + + "" + name + "" + + "s3" + + "" + cfg.ns + "" + + "" + ownerUser + "" + + "" + (encryptionEnabled ? "true" : "false") + "" + + ""; if (logger.isDebugEnabled()) { logger.debug("ECS createBucket XML for '{}': {}", name, createBody); } try { - // Execute mgmt call with cached token (+ refresh on 401, once) - mgmtCallWithRetry401(cfg, token -> { + 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); @@ -212,21 +187,13 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { : ""; if (status == 401) { - throw new EcsUnauthorizedException("ECS createBucket got 401"); + throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS createBucket got 401"); } if (status != 200 && status != 201) { String reason = "HTTP " + status; - if (status == 400) { - final String lb = respBody == null ? "" : respBody.toLowerCase(Locale.ROOT); - if (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")) { - reason = "HTTP 400 bucket name already exists"; - } + 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); @@ -234,26 +201,36 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { } } 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); + 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, AD_KEY_ACCESS)); - final String secKey = valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_SECRET)); - if (vo != null) { - vo.setBucketURL(s3UrlForUI); - if (!isBlank(accKey)) vo.setAccessKey(accKey); - if (!isBlank(secKey)) vo.setSecretKey(secKey); - bucketDao.update(vo.getId(), vo); + 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) { @@ -264,12 +241,14 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { @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); + 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 = "cs-" + acct.getUuid(); + final String username = getUserPrefix(ds) + acct.getUuid(); ensureAccountUserAndSecret(accountId, username, cfg.mgmtUrl, cfg.saUser, cfg.saPass, cfg.ns, cfg.insecure); return true; } @@ -283,19 +262,21 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { 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, AD_KEY_ACCESS)); - final String secretKey = valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_SECRET)); - if (isBlank(accessKey) || isBlank(secretKey)) { + 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 || isBlank(ep.host)) { + if (ep == null || StringUtils.isBlank(ep.host)) { throw new CloudRuntimeException("ECS listBuckets: S3 endpoint not resolvable"); } - final boolean insecure = "true".equalsIgnoreCase(ds.getOrDefault(INSECURE, "false")); + final boolean insecure = "true".equalsIgnoreCase(ds.getOrDefault(EcsConstants.INSECURE, "false")); final java.util.List out = new java.util.ArrayList<>(); try (CloseableHttpClient http = buildHttpClient(insecure)) { @@ -315,16 +296,18 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { 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 = extractAllTags(body, "Name"); + final List names = xml.extractAllTags(body, "Name"); for (String n : names) { - if (isBlank(n)) continue; - final org.apache.cloudstack.storage.object.Bucket b = - new org.apache.cloudstack.storage.object.BucketObject(); + if (StringUtils.isBlank(n)) { + continue; + } + final Bucket b = new BucketObject(); b.setName(n.trim()); out.add(b); } @@ -336,6 +319,7 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { 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); @@ -343,20 +327,23 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { 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, AD_KEY_ACCESS)); - final String secretKey = valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_SECRET)); - if (isBlank(accessKey) || isBlank(secretKey)) { + 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 || isBlank(ep.host)) { + if (ep == null || StringUtils.isBlank(ep.host)) { throw new CloudRuntimeException("ECS listBucketObjects: S3 endpoint not resolvable"); } - final boolean insecure = "true".equalsIgnoreCase(ds.getOrDefault(INSECURE, "false")); + 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) { @@ -366,12 +353,12 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { final String signature = hmacSha1Base64(sts, secretKey); final StringBuilder qs = new StringBuilder("max-keys=1000"); - if (!isBlank(marker)) { - qs.append("&marker=").append(java.net.URLEncoder - .encode(marker, java.nio.charset.StandardCharsets.UTF_8.name())); + 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 String url = ep.scheme + "://" + ep.host + "/" + bucketName + "/?" + qs; final HttpGet get = new HttpGet(url); get.setHeader("Host", ep.host); get.setHeader("Date", dateHdr); @@ -382,25 +369,34 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { 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); } - extractKeysFromListBucketXml(body, keys); + xml.extractKeysFromListBucketXml(body, keys); - final boolean truncated = "true".equalsIgnoreCase(extractTag(body, "IsTruncated")); - if (!truncated) break; + 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; + } - String next = extractTag(body, "NextMarker"); - if (isBlank(next) && !keys.isEmpty()) next = keys.get(keys.size() - 1); - if (isBlank(next)) break; marker = next; } } } catch (Exception e) { throw new CloudRuntimeException("ECS listBucketObjects failed: " + e.getMessage(), e); } + return keys; } @@ -415,7 +411,7 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { final String url = cfg.mgmtUrl + "/object/bucket/" + bucketName + "/deactivate?namespace=" + cfg.ns; try { - return mgmtCallWithRetry401(cfg, token -> { + return tokenManager.callWithRetry401(cfg, token -> { try (CloseableHttpClient http = buildHttpClient(cfg.insecure)) { final HttpPost post = new HttpPost(url); post.setHeader("X-SDS-AUTH-TOKEN", token); @@ -426,7 +422,9 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { ? EntityUtils.toString(r.getEntity(), StandardCharsets.UTF_8) : ""; - if (st == 401) throw new EcsUnauthorizedException("ECS deleteBucket got 401"); + if (st == 401) { + throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS deleteBucket got 401"); + } if (st == 200 || st == 204) { logger.info("ECS bucket deactivated (deleted): '{}'", bucketName); @@ -449,7 +447,7 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { throw new CloudRuntimeException("Failed to delete ECS bucket '" + bucketName + "': HTTP " + st); } } - }); + }, this::buildHttpClient); } catch (CloudRuntimeException cre) { throw cre; } catch (Exception e) { @@ -457,8 +455,15 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { } } - @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 */ } + @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 @@ -470,25 +475,23 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { 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()); + 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); + 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."); + throw new CloudRuntimeException("ECS setBucketPolicy: unsupported policy value '" + policy + + "'. Use 'public', 'private', or raw JSON."); } try { - mgmtCallWithRetry401(cfg, token -> { + tokenManager.callWithRetry401(cfg, token -> { final String current = getBucketPolicyRaw(url, token, cfg.insecure); // "" if none final boolean hasPolicy = current != null && !current.isBlank(); @@ -507,22 +510,26 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { return null; } - final String policyJson = req.startsWith("{") ? req : - ("{\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" + - "}"); + 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) { @@ -539,21 +546,23 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { 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()); + 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 mgmtCallWithRetry401(cfg, token -> { + 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 EcsUnauthorizedException("ECS getBucketPolicy got 401"); + 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(); @@ -571,9 +580,11 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { throw new CloudRuntimeException("ECS getBucketPolicy failed: HTTP " + st + " body=" + body); } } - }); + }, this::buildHttpClient); } catch (Exception e) { - if (e instanceof CloudRuntimeException) throw (CloudRuntimeException) e; + if (e instanceof CloudRuntimeException) { + throw (CloudRuntimeException) e; + } throw new CloudRuntimeException("ECS getBucketPolicy error: " + e.getMessage(), e); } } @@ -595,12 +606,14 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { 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 + ")"; + "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); @@ -614,9 +627,9 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { 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(INSECURE, "false")); + final boolean insecure = "true".equalsIgnoreCase(ds.getOrDefault(EcsConstants.INSECURE, "false")); - if (ep == null || isBlank(ep.host)) { + if (ep == null || StringUtils.isBlank(ep.host)) { logger.warn("ECS: {}BucketVersioning requested but S3 endpoint is not resolvable; skipping.", enable ? "set" : "delete"); return true; @@ -625,85 +638,90 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { final String bucketName = bucket.getName(); final String desired = enable ? "Enabled" : "Suspended"; - final int maxTries = 45; - for (int attempt = 1; attempt <= maxTries; attempt++) { - BucketVO vo = resolveBucketVO(bucket, storeId); - if (vo == null) { - vo = findBucketVOByStoreAndName(storeId, bucketName); - if (vo == null) { - vo = findBucketVOAnyByName(bucketName); - } - } + // 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(); + } - String accessKey = vo != null ? safeString(vo, "getAccessKey") : null; - String secretKey = vo != null ? safeString(vo, "getSecretKey") : null; - - if (!isBlank(accessKey) && !isBlank(secretKey)) { - try (CloseableHttpClient http = buildHttpClient(insecure)) { - setS3BucketVersioning(http, ep.scheme, ep.host, bucketName, accessKey, secretKey, desired); - logger.info("ECS: S3 versioning {} for bucket='{}' using bucket-scoped keys (attempt {}/{}).", - desired, bucketName, attempt, maxTries); - return true; - } catch (Exception e) { - logger.warn("ECS: versioning {} for '{}' (bucket keys) failed on attempt {}/{}: {}", - desired, bucketName, attempt, maxTries, e.getMessage()); - } - } - - long accountId = -1L; + // 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) { } } - if (accountId <= 0) { - accountId = getLongFromGetter(bucket, "getAccountId", -1L); - } - if (accountId <= 0) { - Long aid = resolveAccountIdViaMgmt(bucketName, ds, insecure); - if (aid != null && aid > 0) accountId = aid; - } + } - if (accountId > 0) { - String accKey = valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_ACCESS)); - String secKey = valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_SECRET)); + // Fallback: reflection on BucketTO (if present in this branch) + if (accountId <= 0) { + accountId = getLongFromGetter(bucket, "getAccountId", -1L); + } - if (isBlank(accKey) || isBlank(secKey)) { - final EcsCfg cfg = ecsCfgFromDetails(ds, storeId); - final Account acct = accountDao.findById(accountId); - if (acct != null) { - final String ownerUser = "cs-" + acct.getUuid(); - try { - ensureAccountUserAndSecret(accountId, ownerUser, cfg.mgmtUrl, cfg.saUser, cfg.saPass, cfg.ns, cfg.insecure); - } catch (Exception e) { - logger.debug("ECS: ensureAccountUserAndSecret failed (attempt {}): {}", attempt, e.getMessage()); - } - accKey = valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_ACCESS)); - secKey = valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_SECRET)); - } - } - - if (!isBlank(accKey) && !isBlank(secKey)) { - try (CloseableHttpClient http = buildHttpClient(insecure)) { - setS3BucketVersioning(http, ep.scheme, ep.host, bucketName, accKey, secKey, desired); - logger.info("ECS: S3 versioning {} for bucket='{}' using account-scoped keys (attempt {}/{}).", - desired, bucketName, attempt, maxTries); - return true; - } catch (Exception e) { - logger.warn("ECS: versioning {} for '{}' (account keys) failed on attempt {}/{}: {}", - desired, bucketName, attempt, maxTries, e.getMessage()); - } - } - } - - if (attempt < maxTries) { - try { Thread.sleep(1000L); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } + // 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; } } - logger.warn("ECS: versioning {} for '{}' gave up after {} attempts; leaving as-is.", desired, bucket.getName(), 45); + 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, @@ -711,17 +729,22 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { final String accessKey, final String secretKey, final String status) throws Exception { - final String body = "" + status + ""; + 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(); + final String contentMd5 = base64Md5(bodyBytes); + final String dateHdr = rfc1123Now(); - final String canonicalResource = "/" + bucketName + "?versioning"; + // 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 String url = scheme + "://" + host + "/" + bucketName + "/?versioning"; final HttpPut put = new HttpPut(url); put.setHeader("Host", host); put.setHeader("Date", dateHdr); @@ -732,16 +755,86 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { 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) : ""; + 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); } } } - /** - * Post-create quota changes (best-effort, never throws). - */ + 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) { @@ -754,22 +847,30 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { final String bucketName = bucket.getName(); try { - mgmtCallWithRetry401(cfg, token -> { + 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 EcsUnauthorizedException("ECS get quota got 401"); + if (st == 401) { + throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS get quota got 401"); + } if (st == 200) { - final String xml = r.getEntity() != null ? EntityUtils.toString(r.getEntity(), StandardCharsets.UTF_8) : ""; - currentGb = parseIntTag(xml, "blockSize"); - if (currentGb == null) currentGb = parseIntTag(xml, "notificationSize"); + 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 (EcsUnauthorizedException u) { + } catch (EcsMgmtTokenManager.EcsUnauthorizedException u) { throw u; } catch (Exception e) { logger.debug("ECS get quota for {} failed (non-fatal): {}", bucketName, e.getMessage()); @@ -781,11 +882,11 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { } final String quotaBody = - "" + - "" + size + "" + - "" + size + "" + - "" + cfg.ns + "" + - ""; + "" + + "" + size + "" + + "" + size + "" + + "" + cfg.ns + "" + + ""; final HttpPut put = new HttpPut(cfg.mgmtUrl + "/object/bucket/" + bucketName + "/quota"); put.setHeader("X-SDS-AUTH-TOKEN", token); @@ -794,8 +895,13 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { 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 EcsUnauthorizedException("ECS set quota got 401"); + 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; @@ -805,7 +911,7 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { 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()); } @@ -818,7 +924,7 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { // ---------------- helpers ---------------- - private static final class EcsCfg { + static final class EcsCfg { final String mgmtUrl; final String saUser; final String saPass; @@ -835,168 +941,68 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { } private EcsCfg ecsCfgFromDetails(final Map ds, final long storeId) { - final String mgmtUrl = trimTail(ds.get(MGMT_URL)); - final String saUser = ds.get(SA_USER); - final String saPass = ds.get(SA_PASS); - final String ns = org.apache.commons.lang3.StringUtils.isBlank(ds.get(NAMESPACE)) ? "default" : ds.get(NAMESPACE); - final boolean insecure = "true".equalsIgnoreCase(ds.getOrDefault(INSECURE, "false")); + 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 (isBlank(mgmtUrl) || isBlank(saUser) || isBlank(saPass)) { + 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 T mgmtCallWithRetry401(final EcsCfg cfg, final WithToken op) throws Exception { - try { - return op.run(getAuthToken(cfg.mgmtUrl, cfg.saUser, cfg.saPass, cfg.insecure)); - } catch (EcsUnauthorizedException u) { - invalidateToken(cfg.mgmtUrl, cfg.saUser); - return op.run(getAuthToken(cfg.mgmtUrl, cfg.saUser, cfg.saPass, cfg.insecure)); + private String getUserPrefix(final Map ds) { + String p = null; + if (ds != null) { + p = ds.get(EcsConstants.USER_PREFIX); } - } - - private void invalidateToken(final String mgmtUrl, final String user) { - final TokenKey key = new TokenKey(trimTail(mgmtUrl), user); - TOKEN_CACHE.remove(key); - } - - private String getAuthToken(final String mgmtUrl, final String user, final String pass, final boolean insecure) { - final String mu = trimTail(mgmtUrl); - final TokenKey key = new TokenKey(mu, user); - - 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, user, pass, insecure); - TOKEN_CACHE.put(key, fresh); - return fresh.token; - } - } - - private TokenEntry loginAndGetTokenFresh(final String mgmtUrl, final String user, final String pass, final boolean insecure) { - try (CloseableHttpClient http = buildHttpClient(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) { - throw new CloudRuntimeException("ECS /login failed: HTTP " + status); - } - 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); - - if (logger.isDebugEnabled()) { - logger.debug("ECS token fetched for user='{}' (maxAge={}s, effective={}s)", user, maxAgeSec, effectiveSec); - } - return new TokenEntry(token, expiresAtMs); - } - } catch (Exception e) { - throw new CloudRuntimeException("Failed to obtain ECS auth token: " + e.getMessage(), e); + if (StringUtils.isBlank(p)) { + return EcsConstants.DEFAULT_USER_PREFIX; } + return p.trim(); } private static String valueOrNull(final AccountDetailVO d) { - return d == null ? null : d.getValue(); - } - - private static Integer safeIntFromGetter(final Object o, final String getter) { - try { - Object v = o.getClass().getMethod(getter).invoke(o); - if (v instanceof Number) return ((Number) v).intValue(); - if (v instanceof String && !((String) v).isEmpty()) return Integer.parseInt((String) v); - } catch (Exception ignore) { } - return null; - } - - private static boolean getBooleanFlagLoose(final Object o, final String getMethod, final String isMethod, final boolean defVal) { - if (o == null) return defVal; - Object v = null; - try { v = o.getClass().getMethod(getMethod).invoke(o); } catch (NoSuchMethodException ignored) { } catch (Exception ignored) { } - if (v == null) { - try { v = o.getClass().getMethod(isMethod).invoke(o); } catch (NoSuchMethodException ignored) { } catch (Exception ignored) { } + if (d == null) { + return null; } - if (v == null) return defVal; - - if (v instanceof Boolean) return (Boolean) v; - if (v instanceof Number) return ((Number) v).intValue() != 0; - if (v instanceof String) { - String s = ((String) v).trim(); - if ("true".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s) || "on".equalsIgnoreCase(s) || "1".equals(s)) return true; - if ("false".equalsIgnoreCase(s) || "no".equalsIgnoreCase(s) || "off".equalsIgnoreCase(s) || "0".equals(s)) return false; - } - return defVal; - } - - private static Integer parseIntTag(final String xml, final String tag) { - if (xml == null) return null; - final String open = "<" + tag + ">"; - final String close = ""; - final int i = xml.indexOf(open); - final int j = xml.indexOf(close); - if (i >= 0 && j > i) { - final String val = xml.substring(i + open.length(), j).trim(); - try { - return Integer.parseInt(val); - } catch (NumberFormatException ignore) { } - } - return null; - } - - private static String parseXmlTag(final String xml, final String tag) { - if (xml == null) return null; - final String open = "<" + tag + ">"; - final String close = ""; - final int i = xml.indexOf(open); - final int j = xml.indexOf(close); - if (i >= 0 && j > i) { - return xml.substring(i + open.length(), j); - } - return null; - } - - private static boolean isBlank(final String s) { - return s == null || s.trim().isEmpty(); + return d.getValue(); } private static String trimTail(final String s) { - if (s == null) return null; - return s.endsWith("/") ? s.substring(0, s.length() - 1) : 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; + 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); + 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(); + if (!insecure) { + return HttpClients.createDefault(); + } try { final TrustStrategy trustAll = (chain, authType) -> true; final SSLContext sslContext = SSLContextBuilder.create() @@ -1011,25 +1017,41 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { } } - /** GET /object/user-secret-keys/{username} and parse any existing secrets. */ + // 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 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 EcsUnauthorizedException("ECS fetch secrets got 401"); + if (st == 401) { + throw new EcsMgmtTokenManager.EcsUnauthorizedException("ECS fetch secrets got 401"); + } + if (st == 200) { - final String xml = r.getEntity() != null ? EntityUtils.toString(r.getEntity(), StandardCharsets.UTF_8) : ""; + 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 = parseXmlTag(xml, "secret_key_1"); - final String s2 = parseXmlTag(xml, "secret_key_2"); - final String e1 = parseXmlTag(xml, "secret_key_1_exist"); - final String e2 = parseXmlTag(xml, "secret_key_2_exist"); - if ("true".equalsIgnoreCase(e1) && !isBlank(s1)) out.add(s1.trim()); - if ("true".equalsIgnoreCase(e2) && !isBlank(s2)) out.add(s2.trim()); + + 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(); } } @@ -1041,30 +1063,37 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { final String saPass, final String ns, final boolean insecure) { - final String haveAcc = valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_ACCESS)); - final String haveSec = valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_SECRET)); + 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 { - mgmtCallWithRetry401(cfg, token -> { + tokenManager.callWithRetry401(cfg, token -> { try (CloseableHttpClient http = buildHttpClient(insecure)) { // Ensure/CREATE user (idempotent) final String createUserXml = - "" + - "" + username + "" + - "" + ns + "" + - "" + - ""; + "" + + "" + 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 EcsUnauthorizedException("ECS ensure user got 401"); + 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")) { @@ -1076,27 +1105,34 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { } // If ACS already has key -> do NOT create another. - if (!isBlank(haveAcc) && !isBlank(haveSec)) { + 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 { - List ecsKeys = fetchEcsUserSecrets(http, mgmtUrl, token, username); + final List ecsKeys = fetchEcsUserSecrets(http, mgmtUrl, token, username); if (ecsKeys.isEmpty()) { final String skXml = - "" + - "" + ns + "" + - "" + haveSec + "" + - ""; + "" + + "" + 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 EcsUnauthorizedException("ECS reconcile secret got 401"); + 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")) { @@ -1106,25 +1142,32 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { } } } - } catch (EcsUnauthorizedException u) { + } 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 { - List ecsKeys = fetchEcsUserSecrets(http, mgmtUrl, token, username); + final List ecsKeys = fetchEcsUserSecrets(http, mgmtUrl, token, username); if (!ecsKeys.isEmpty()) { final String adopt = ecsKeys.get(0); - if (isBlank(haveAcc)) accountDetailsDao.addDetail(accountId, AD_KEY_ACCESS, username, false); - if (isBlank(haveSec)) accountDetailsDao.addDetail(accountId, AD_KEY_SECRET, adopt, false); + + 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 (EcsUnauthorizedException u) { + } 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()); @@ -1133,41 +1176,60 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { // 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 + "" + - ""; + "" + + "" + 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 EcsUnauthorizedException("ECS create secret got 401"); + 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")) { - List ecsKeys = fetchEcsUserSecrets(http, mgmtUrl, token, username); + final List ecsKeys = fetchEcsUserSecrets(http, mgmtUrl, token, username); if (!ecsKeys.isEmpty()) { final String adopt = ecsKeys.get(0); - if (isBlank(haveAcc)) accountDetailsDao.addDetail(accountId, AD_KEY_ACCESS, username, false); - if (isBlank(haveSec)) accountDetailsDao.addDetail(accountId, AD_KEY_SECRET, adopt, false); + + 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 (isBlank(haveAcc)) accountDetailsDao.addDetail(accountId, AD_KEY_ACCESS, username, false); - if (isBlank(haveSec)) accountDetailsDao.addDetail(accountId, AD_KEY_SECRET, newSecret, false); + 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) { @@ -1179,50 +1241,72 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { 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; } + + 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(S3_HOST)); // accept host or URL from UI + String host = normalizeHostOnly(ds.get(EcsConstants.S3_HOST)); // accept host or URL from UI final String scheme = "https"; - if (isBlank(host)) { - // last-resort (but prefer failing loudly earlier) + + 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(S3_HOST)); - if (isBlank(host)) host = normalizeHostOnly(ds.get("host")); + 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(MGMT_URL)); - final String saUser = ds.get(SA_USER); - final String saPass = ds.get(SA_PASS); - if (isBlank(mgmtUrl) || isBlank(saUser) || isBlank(saPass)) return null; + 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); - final EcsCfg cfg = new EcsCfg(mgmtUrl, saUser, saPass, - org.apache.commons.lang3.StringUtils.isBlank(ds.get(NAMESPACE)) ? "default" : ds.get(NAMESPACE), + 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 mgmtCallWithRetry401(cfg, token -> { + return tokenManager.callWithRetry401(cfg, token -> { try (CloseableHttpClient http = buildHttpClient(insecure)) { final String owner = fetchBucketOwnerViaMgmt(http, mgmtUrl, token, bucketName); - if (!isBlank(owner) && owner.startsWith("cs-") && owner.length() > 3) { - final String uuid = owner.substring(3); + + if (!StringUtils.isBlank(owner) && !StringUtils.isBlank(prefix) && owner.startsWith(prefix) && owner.length() > prefix.length()) { + final String uuid = owner.substring(prefix.length()); try { - Account acct = accountDao.findByUuid(uuid); - if (acct != null) return acct.getId(); - } catch (Throwable ignore) { } + 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; @@ -1232,267 +1316,179 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { 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 EcsUnauthorizedException("ECS fetch bucket owner got 401"); - if (st == 200) { - final String xml = r.getEntity() != null ? EntityUtils.toString(r.getEntity(), StandardCharsets.UTF_8) : ""; - final String owner = parseXmlTag(xml, "owner"); - if (!isBlank(owner)) return owner.trim(); + + 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 helpers / VO lookups ---------- + // ---------- Reflection helper (only where needed) ---------- private static long getLongFromGetter(final Object o, final String getter, final long defVal) { - if (o == null) return defVal; + if (o == null) { + return defVal; + } try { - 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) { } + 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 static String safeString(final Object o, final String getter) { - if (o == null) return null; - try { - Object v = o.getClass().getMethod(getter).invoke(o); - return v != null ? v.toString() : null; - } catch (Throwable ignore) { } - return null; + private BucketVO resolveBucketVO(final BucketTO bucket) { + if (bucket == null) return null; + + long id = getLongFromGetter(bucket, "getId", -1L); + if (id > 0) { + return bucketDao.findById(id); } - - private BucketVO resolveBucketVO(final BucketTO bucket, final long storeId) { - long id = getLongFromGetter(bucket, "getId", -1L); - if (id <= 0) id = getLongFromGetter(bucket, "getBucketId", -1L); - if (id > 0) { - try { return bucketDao.findById(id); } catch (Throwable ignore) { } - } - - String uuid = null; - try { uuid = (String) bucket.getClass().getMethod("getUuid").invoke(bucket); } catch (Throwable ignore) { } - if (isBlank(uuid)) { - try { uuid = (String) bucket.getClass().getMethod("getId").invoke(bucket); } catch (Throwable ignore) { } - } - if (!isBlank(uuid)) { - try { - java.lang.reflect.Method m = bucketDao.getClass().getMethod("findByUuid", String.class); - Object r = m.invoke(bucketDao, uuid); - if (r instanceof BucketVO) return (BucketVO) r; - } catch (NoSuchMethodException ignored) { } catch (Throwable ignore) { } - } - - final String name = bucket.getName(); - try { - try { - java.lang.reflect.Method m1 = bucketDao.getClass().getMethod("findByName", String.class); - Object r1 = m1.invoke(bucketDao, name); - if (r1 instanceof BucketVO) return (BucketVO) r1; - } catch (NoSuchMethodException ignored) { } - try { - java.lang.reflect.Method m2 = bucketDao.getClass().getMethod("findByName", String.class, long.class); - Object r2 = m2.invoke(bucketDao, name, storeId); - if (r2 instanceof BucketVO) return (BucketVO) r2; - } catch (NoSuchMethodException ignored) { } - try { - java.lang.reflect.Method m3 = bucketDao.getClass().getMethod("findByName", long.class, String.class); - Object r3 = m3.invoke(bucketDao, storeId, name); - if (r3 instanceof BucketVO) return (BucketVO) r3; - } catch (NoSuchMethodException ignored) { } - } catch (Throwable t) { - logger.debug("ECS: resolveBucketVO by name '{}' failed: {}", name, t.getMessage()); - } - return null; - } - - @SuppressWarnings("unchecked") - private BucketVO findBucketVOByStoreAndName(final long storeId, final String name) { - try { - java.lang.reflect.Method m = bucketDao.getClass().getMethod("listByStoreId", long.class); - Object res = m.invoke(bucketDao, storeId); - if (res instanceof List) { - for (Object o : (List) res) { - if (o instanceof BucketVO) { - BucketVO vo = (BucketVO) o; - try { if (name.equals(vo.getName())) return vo; } catch (Throwable ignore) { } - } - } - } - } catch (NoSuchMethodException ignored) { - } catch (Throwable t) { - logger.debug("ECS: listByStoreId fallback failed: {}", t.getMessage()); - } - return null; - } - - @SuppressWarnings("unchecked") - private BucketVO findBucketVOAnyByName(final String name) { - try { - java.lang.reflect.Method m = bucketDao.getClass().getMethod("listAll"); - Object res = m.invoke(bucketDao); - if (res instanceof List) { - for (Object o : (List) res) { - if (o instanceof BucketVO) { - BucketVO vo = (BucketVO) o; - try { if (name.equals(vo.getName())) return vo; } catch (Throwable ignore) { } - } - } - } - } catch (NoSuchMethodException ignored) { - } catch (Throwable t) { - logger.debug("ECS: listAll scan failed: {}", t.getMessage()); - } - return null; - } - - /** First occurrence of value, no namespaces. Returns null if not found. */ - private static String extractTag(final String xml, final String tag) { - if (xml == null) return null; - final String open = "<" + tag + ">"; - final String close = ""; - 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(); - } - - /** All occurrences of value, no namespaces. */ - private static List extractAllTags(final String xml, final String tag) { - final List out = new java.util.ArrayList<>(); - if (xml == null) return out; - final String open = "<" + tag + ">"; - final String close = ""; - 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; - } - - /** Pulls every ......... key into 'keys'. */ - private static 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(); - } + return null; } private static String base64Md5(final byte[] data) throws Exception { - java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5"); - byte[] digest = md.digest(data); + 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 { - javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA1"); - javax.crypto.spec.SecretKeySpec sk = new javax.crypto.spec.SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA1"); + 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); - byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + final byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(raw); } private static String rfc1123Now() { - java.time.format.DateTimeFormatter fmt = java.time.format.DateTimeFormatter + 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). */ + // 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 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 ""; + 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 (EcsUnauthorizedException u) { + } catch (EcsMgmtTokenManager.EcsUnauthorizedException u) { throw u; } catch (Exception e) { throw new CloudRuntimeException("ECS getBucketPolicy error: " + e.getMessage(), e); } } - /** PUT /policy with JSON. */ + // 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 EcsUnauthorizedException("ECS putBucketPolicy got 401"); - if (st == 200 || st == 204) return; - final String body = resp.getEntity() == null ? "" : - EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); + 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 (EcsUnauthorizedException u) { + } catch (EcsMgmtTokenManager.EcsUnauthorizedException u) { throw u; } catch (Exception e) { throw new CloudRuntimeException("ECS setBucketPolicy error: " + e.getMessage(), e); } } - /** DELETE /policy to make bucket private. */ + // 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 EcsUnauthorizedException("ECS deleteBucketPolicyHttp got 401"); - if (st == 200 || st == 204) return; - final String body = resp.getEntity() == null ? "" : - EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); + 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 (EcsUnauthorizedException u) { + } 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=... */ + // 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(MGMT_URL)); - final String saUser = ds.get(SA_USER); - final String saPass = ds.get(SA_PASS); - final String ns = org.apache.commons.lang3.StringUtils.isBlank(ds.get(NAMESPACE)) ? "default" : ds.get(NAMESPACE); - final boolean insecure = "true".equalsIgnoreCase(ds.getOrDefault(INSECURE, "false")); + 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 (isBlank(bucketName)) { + if (StringUtils.isBlank(bucketName)) { logger.warn("ecsBucketExists: bucket name is blank; treating as non-existent."); return false; } - if (isBlank(mgmtUrl) || isBlank(saUser) || isBlank(saPass)) { + 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; } @@ -1500,7 +1496,7 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { final EcsCfg cfg = new EcsCfg(mgmtUrl, saUser, saPass, ns, insecure); try { - return mgmtCallWithRetry401(cfg, token -> { + return tokenManager.callWithRetry401(cfg, token -> { try (CloseableHttpClient http = buildHttpClient(insecure)) { final String url = mgmtUrl + "/object/bucket/" @@ -1519,19 +1515,25 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { ? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8) : ""; - if (st == 401) throw new EcsUnauthorizedException("ecsBucketExists got 401"); + if (st == 401) { + throw new EcsMgmtTokenManager.EcsUnauthorizedException("ecsBucketExists got 401"); + } - if (st == 200) return true; - if (st == 404) return false; + if (st == 200) { + return true; + } + if (st == 404) { + return false; + } if (st == 400) { - final String errCode = parseXmlTag(body, "code"); - final String errDetail = parseXmlTag(body, "details"); - final String errDesc = parseXmlTag(body, "description"); + 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 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 String lowerDesc = errDesc == null ? "" : errDesc.toLowerCase(Locale.ROOT); final boolean notFoundByCode = "1004".equals(errCode); final boolean notFoundByText = @@ -1540,14 +1542,16 @@ public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { || lowerDesc.contains("unable to find entity with the given id") || lowerDesc.contains("request parameter cannot be found"); - if (notFoundByCode || notFoundByText) return false; + 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 00000000000..3db03067981 --- /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 = ""; + 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 = ""; + + 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 index 0ba2918a3be..213b73a06d3 100644 --- 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 @@ -29,6 +29,7 @@ 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; @@ -36,7 +37,7 @@ 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; +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; @@ -52,16 +53,6 @@ public class EcsObjectStoreLifeCycleImpl implements ObjectStoreLifeCycle { private static final Logger LOG = LogManager.getLogger(EcsObjectStoreLifeCycleImpl.class); - // detail keys coming from the API - private static final String MGMT_URL = "mgmt_url"; - private static final String SA_USER = "sa_user"; - private static final String SA_PASS = "sa_password"; - private static final String INSECURE = "insecure"; - - // optional details (currently not used in persistence logic but accepted) - private static final String S3_HOST = "s3_host"; - private static final String NAMESPACE = "namespace"; - private static final String PROVIDER_NAME = "ECS"; @Inject @@ -79,19 +70,21 @@ public class EcsObjectStoreLifeCycleImpl implements ObjectStoreLifeCycle { final String url = getString(dsInfos, "url", true); final String name = getString(dsInfos, "name", true); - final Long size = getLong(dsInfos, "size"); + final Long size = getLong(dsInfos, "size"); // optional final String providerName = getProviderName(dsInfos); - final Map details = getDetails(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(S3_HOST), details.get(NAMESPACE)); + 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); @@ -228,14 +221,14 @@ public class EcsObjectStoreLifeCycleImpl implements ObjectStoreLifeCycle { } private static EcsConfig verifyAndNormalize(final Map details) { - final String mgmtUrl = trim(details.get(MGMT_URL)); - final String saUser = safe(details.get(SA_USER)); - final String saPass = safe(details.get(SA_PASS)); - final boolean insecure = Boolean.parseBoolean(details.getOrDefault(INSECURE, "false")); + 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(MGMT_URL, mgmtUrl); - verifyRequiredDetail(SA_USER, saUser); - verifyRequiredDetail(SA_PASS, saPass); + verifyRequiredDetail(EcsConstants.MGMT_URL, mgmtUrl); + verifyRequiredDetail(EcsConstants.SA_USER, saUser); + verifyRequiredDetail(EcsConstants.SA_PASS, saPass); return new EcsConfig(mgmtUrl, saUser, saPass, insecure); } @@ -247,10 +240,11 @@ public class EcsObjectStoreLifeCycleImpl implements ObjectStoreLifeCycle { } private static void applyCanonicalDetails(final Map details, final EcsConfig cfg) { - details.put(MGMT_URL, cfg.mgmtUrl); - details.put(SA_USER, cfg.saUser); - details.put(SA_PASS, cfg.saPass); - details.put(INSECURE, Boolean.toString(cfg.insecure)); + 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, diff --git a/ui/src/views/infra/AddObjectStorage.vue b/ui/src/views/infra/AddObjectStorage.vue index 3f46bd6ae39..935c413d5cc 100644 --- a/ui/src/views/infra/AddObjectStorage.vue +++ b/ui/src/views/infra/AddObjectStorage.vue @@ -97,9 +97,9 @@ - + @@ -126,7 +126,15 @@ - + + + + + + + Allow insecure HTTPS (set insecure=true) @@ -210,6 +218,7 @@ export default { mgmtUrl: '', s3Host: '', namespace: '', + userPrefix: 'cs-', insecure: false }) this.rules = reactive({ @@ -252,14 +261,8 @@ export default { data['details[4].key'] = 'iamUrl' data['details[4].value'] = values.iamUrl } else if (provider === 'ECS') { - // ECS details matching your CLI example: - // add objectstoragepool name=ecsstore-4 provider=ECS url=https://ecs.earthlink.dev - // details[0].key=mgmt_url details[0].value=https://ecs-api.elcld.net - // details[1].key=s3_host details[1].value=ecs.earthlink.dev - // details[2].key=sa_user details[2].value=cloudstack - // details[3].key=sa_password details[3].value=... - // details[4].key=namespace details[4].value=cloudstack - // details[5].key=insecure details[5].value=true + // ECS details: + // details[0]=mgmt_url, [1]=s3_host, [2]=sa_user, [3]=sa_password, [4]=namespace, [5]=user_prefix, [6]=insecure data['details[0].key'] = 'mgmt_url' data['details[0].value'] = values.mgmtUrl @@ -276,8 +279,18 @@ export default { data['details[4].key'] = 'namespace' data['details[4].value'] = values.namespace - data['details[5].key'] = 'insecure' - data['details[5].value'] = values.insecure ? 'true' : 'false' + // Optional; only send if user entered something (driver defaults to cs- when missing) + if (values.userPrefix && values.userPrefix.trim() !== '') { + data['details[5].key'] = 'user_prefix' + data['details[5].value'] = values.userPrefix.trim() + } else { + // keep ordering stable for insecure when prefix omitted + data['details[5].key'] = 'user_prefix' + data['details[5].value'] = 'cs-' + } + + data['details[6].key'] = 'insecure' + data['details[6].value'] = values.insecure ? 'true' : 'false' } else { // Generic non-Cloudian, non-ECS object stores data['details[0].key'] = 'accesskey'