From 8f36ebfde35543925c9813525ac5a4eb47696a21 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 18 Nov 2025 15:46:35 +0300 Subject: [PATCH 1/5] Adding New Dell ECS Plugin to the API & editing Frontend for the ECS Provider --- client/pom.xml | 5 + plugins/storage/object/ECS/pom.xml | 47 + .../driver/EcsObjectStoreDriverImpl.java | 1490 +++++++++++++++++ .../EcsObjectStoreLifeCycleImpl.java | 255 +++ .../provider/EcsObjectStoreProviderImpl.java | 90 + .../storage-object-ecs/module.properties | 18 + .../spring-storage-object-ecs-context.xml | 14 + ui/src/views/infra/AddObjectStorage.vue | 131 +- ui/src/views/storage/CreateBucket.vue | 93 +- ui/src/views/storage/UpdateBucket.vue | 78 +- 10 files changed, 2153 insertions(+), 68 deletions(-) create mode 100644 plugins/storage/object/ECS/pom.xml create mode 100644 plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsObjectStoreDriverImpl.java create mode 100644 plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/EcsObjectStoreLifeCycleImpl.java create mode 100644 plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/provider/EcsObjectStoreProviderImpl.java create mode 100644 plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/module.properties create mode 100644 plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/spring-storage-object-ecs-context.xml diff --git a/client/pom.xml b/client/pom.xml index d8fa433d5be3..aeb2f27247f6 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -632,6 +632,11 @@ cloud-plugin-storage-object-minio ${project.version} + + org.apache.cloudstack + cloud-plugin-storage-object-ecs + ${project.version} + org.apache.cloudstack cloud-plugin-storage-object-ceph diff --git a/plugins/storage/object/ECS/pom.xml b/plugins/storage/object/ECS/pom.xml new file mode 100644 index 000000000000..c19ce1698aa9 --- /dev/null +++ b/plugins/storage/object/ECS/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + cloud-plugin-storage-object-ecs + Apache CloudStack Plugin - ECS object storage provider + + org.apache.cloudstack + cloudstack-plugins + 4.23.0.0-SNAPSHOT + ../../../pom.xml + + + + org.apache.cloudstack + cloud-engine-storage + ${project.version} + + + org.apache.cloudstack + cloud-engine-storage-object + ${project.version} + + + org.apache.cloudstack + cloud-engine-schema + ${project.version} + + + diff --git a/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsObjectStoreDriverImpl.java b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsObjectStoreDriverImpl.java new file mode 100644 index 000000000000..9da578177268 --- /dev/null +++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsObjectStoreDriverImpl.java @@ -0,0 +1,1490 @@ +/* ... license header unchanged ... */ +package org.apache.cloudstack.storage.datastore.driver; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javax.inject.Inject; +import javax.net.ssl.SSLContext; + +import org.apache.http.client.methods.HttpDelete; + +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 org.apache.cloudstack.context.CallContext; +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.dao.AccountDao; +import com.cloud.utils.exception.CloudRuntimeException; + +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.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.entity.StringEntity; +import org.apache.http.util.EntityUtils; +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.conn.ssl.NoopHostnameVerifier; +import org.apache.http.ssl.SSLContextBuilder; +import org.apache.http.ssl.TrustStrategy; + +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. http://ecs.mhk.com:4443 (Mgmt API) + 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 ONLY (no scheme) + + // Per-account keys (shown in UI via BucketVO) + private static final String AD_KEY_ACCESS = "ecs.accesskey"; + private static final String AD_KEY_SECRET = "ecs.secretkey"; + + @Inject private AccountDao accountDao; + @Inject private AccountDetailsDao accountDetailsDao; + @Inject private BucketDao bucketDao; + @Inject private ObjectStoreDetailsDao storeDetailsDao; + + public EcsObjectStoreDriverImpl() { } + + @Override + public DataStoreTO getStoreTO(final DataStore store) { + return null; + } + + // ---------------- create bucket ---------------- + + @Override + public Bucket createBucket(final Bucket bucket, final boolean objectLock) { + final long storeId = bucket.getObjectStoreId(); + final String name = bucket.getName(); + + if (objectLock) { + throw new CloudRuntimeException("Dell ECS doesn't support this feature: object locking"); + } + + final Map ds = storeDetailsDao.getDetails(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")); + + if (isBlank(mgmtUrl) || isBlank(saUser) || isBlank(saPass)) { + throw new CloudRuntimeException("ECS createBucket: missing mgmt_url/sa_user/sa_password for store id=" + storeId); + } + + // Resolve owner username for this bucket + final BucketVO vo = bucketDao.findById(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(); + + // Ensure per-account credentials exist (single-key policy with adopt-if-exists) + ensureAccountUserAndSecret(accountId, ownerUser, mgmtUrl, saUser, saPass, ns, insecure); + + // Quota from UI (INT GB) + Integer quotaGb = null; + try { + quotaGb = safeIntFromGetter(bucket, "getQuota"); + if (quotaGb == null) quotaGb = safeIntFromGetter(bucket, "getSize"); + } catch (Throwable ignored) { } + + final int blockSizeGb = quotaGb != null && quotaGb > 0 ? quotaGb : 2; + final int notifSizeGb = quotaGb != null && quotaGb > 0 ? quotaGb : 1; + + // Encryption flag (true/false) — from request first, fall back to VO if present + boolean encryptionEnabled = + getBooleanFlagLoose(bucket, "getEncryption", "isEncryption", false) || + getBooleanFlagLoose(bucket, "getEncryptionEnabled", "isEncryptionEnabled", false); + + if (!encryptionEnabled && vo != null) { + encryptionEnabled = + getBooleanFlagLoose(vo, "getEncryption", "isEncryption", false) || + getBooleanFlagLoose(vo, "getEncryptionEnabled", "isEncryptionEnabled", false); + } + + logger.info("ECS createBucket flags for '{}': encryptionEnabled={}", name, encryptionEnabled); + + final String createBody = + "" + + "" + blockSizeGb + "" + + "" + notifSizeGb + "" + + "" + name + "" + + "s3" + + "" + ns + "" + + "" + ownerUser + "" + + "" + (encryptionEnabled ? "true" : "false") + "" + + ""; + + if (logger.isDebugEnabled()) { + logger.debug("ECS createBucket XML for '{}': {}", name, createBody); + } + + // Fresh token per operation + final String token = loginAndGetToken(mgmtUrl, saUser, saPass, insecure); + + try (CloseableHttpClient http = buildHttpClient(insecure)) { + final HttpPost post = new HttpPost(mgmtUrl + "/object/bucket"); + post.setHeader("X-SDS-AUTH-TOKEN", token); + post.setHeader("Content-Type", "application/xml"); + post.setEntity(new StringEntity(createBody, StandardCharsets.UTF_8)); + + try (CloseableHttpResponse resp = http.execute(post)) { + final int status = resp.getStatusLine().getStatusCode(); + final String respBody = resp.getEntity() != null + ? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8) + : ""; + + if (status != 200 && status != 201) { + String reason = "HTTP " + status; + if (status == 400) { + final String lb = respBody == null ? "" : respBody.toLowerCase(java.util.Locale.ROOT); + // Make duplicate-name errors explicit for the UI + 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"; + } + } + logger.error("ECS create bucket failed: {} body={}", reason, respBody); + throw new CloudRuntimeException("Failed to create ECS bucket " + name + ": " + reason); + } + } + + // ---------- UI URL: show S3 endpoint, not mgmt ---------- + 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, ns, quotaGb != null ? quotaGb : blockSizeGb, encryptionEnabled, s3UrlForUI); + + // Persist UI-visible details on the bucket record (S3 URL and keys for user visibility) + 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); + } + + return bucket; + } catch (Exception e) { + throw new CloudRuntimeException("Failed to create ECS bucket " + name + ": " + e.getMessage(), e); + } + } + + @Override + public boolean createUser(final long accountId, final long storeId) { + final Account acct = accountDao.findById(accountId); + if (acct == null) throw new CloudRuntimeException("ECS createUser: account not found: id=" + accountId); + + final Map ds = storeDetailsDao.getDetails(storeId); + final 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")); + + if (isBlank(mgmtUrl) || isBlank(saUser) || isBlank(saPass)) { + throw new CloudRuntimeException("ECS createUser: missing mgmt_url/sa_user/sa_password for store"); + } + + final String username = "cs-" + acct.getUuid(); + ensureAccountUserAndSecret(accountId, username, mgmtUrl, saUser, saPass, ns, insecure); + return true; + } + + // ---------------- S3: list buckets (SigV2, path-style GET /) ---------------- + @Override + public List listBuckets(final long storeId) { + final Map ds = storeDetailsDao.getDetails(storeId); + + final CallContext ctx = CallContext.current(); + if (ctx == null || ctx.getCallingAccount() == null) { + throw new CloudRuntimeException("ECS listBuckets: no calling account in context."); + } + final long accountId = ctx.getCallingAccount().getId(); + final String accessKey = valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_ACCESS)); + final String secretKey = valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_SECRET)); + if (isBlank(accessKey) || 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)) { + throw new CloudRuntimeException("ECS listBuckets: S3 endpoint not resolvable"); + } + + final boolean insecure = "true".equalsIgnoreCase(ds.getOrDefault(INSECURE, "false")); + final java.util.List out = new java.util.ArrayList<>(); + + try (CloseableHttpClient http = buildHttpClient(insecure)) { + final String dateHdr = rfc1123Now(); + final String canonicalResource = "/"; // ListAllMyBuckets + final String sts = "GET\n\n\n" + dateHdr + "\n" + canonicalResource; + final String signature = hmacSha1Base64(sts, secretKey); + + final String url = ep.scheme + "://" + ep.host + "/"; + final HttpGet get = new HttpGet(url); + get.setHeader("Host", ep.host); + get.setHeader("Date", dateHdr); + get.setHeader("Authorization", "AWS " + accessKey + ":" + signature); + + try (CloseableHttpResponse resp = http.execute(get)) { + final int st = resp.getStatusLine().getStatusCode(); + final String body = resp.getEntity() != null + ? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8) + : ""; + if (st != 200) { + logger.error("ECS listBuckets failed: HTTP {} body={}", st, body); + throw new CloudRuntimeException("ECS listBuckets failed: HTTP " + st); + } + + // xyz (ListAllMyBuckets) + final List names = 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(); + b.setName(n.trim()); + out.add(b); + } + } + } catch (Exception e) { + throw new CloudRuntimeException("ECS listBuckets failed: " + e.getMessage(), e); + } + + return out; + } + + // ---------------- S3: list objects in a bucket (SigV2, path-style; mirrors your curl) ---------------- + public List listBucketObjects(final String bucketName, final long storeId) { + final Map ds = storeDetailsDao.getDetails(storeId); + + final CallContext ctx = CallContext.current(); + if (ctx == null || ctx.getCallingAccount() == null) { + throw new CloudRuntimeException("ECS listBucketObjects: no calling account in context"); + } + final long accountId = ctx.getCallingAccount().getId(); + final String accessKey = valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_ACCESS)); + final String secretKey = valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_SECRET)); + if (isBlank(accessKey) || 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)) { + throw new CloudRuntimeException("ECS listBucketObjects: S3 endpoint not resolvable"); + } + final boolean insecure = "true".equalsIgnoreCase(ds.getOrDefault(INSECURE, "false")); + + final List keys = new java.util.ArrayList<>(); + String marker = null; // pagination + try (CloseableHttpClient http = buildHttpClient(insecure)) { + while (true) { + final String dateHdr = rfc1123Now(); + + final String canonicalResource = "/" + bucketName + "/"; + final String sts = "GET\n\n\n" + dateHdr + "\n" + canonicalResource; + final String signature = hmacSha1Base64(sts, secretKey); + + final StringBuilder qs = new StringBuilder("max-keys=1000"); + if (!isBlank(marker)) { + qs.append("&marker=").append(java.net.URLEncoder + .encode(marker, java.nio.charset.StandardCharsets.UTF_8.name())); + } + final String url = ep.scheme + "://" + ep.host + "/" + bucketName + "/?" + qs; + + final HttpGet get = new HttpGet(url); + get.setHeader("Host", ep.host); + get.setHeader("Date", dateHdr); + get.setHeader("Authorization", "AWS " + accessKey + ":" + signature); + + try (CloseableHttpResponse resp = http.execute(get)) { + final int st = resp.getStatusLine().getStatusCode(); + final String body = resp.getEntity() != null + ? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8) + : ""; + if (st != 200) { + logger.error("ECS listBucketObjects '{}' failed: HTTP {} body={}", bucketName, st, body); + throw new CloudRuntimeException("ECS listBucketObjects failed: HTTP " + st); + } + + // Collect keys from ... + extractKeysFromListBucketXml(body, keys); + + final boolean truncated = "true".equalsIgnoreCase(extractTag(body, "IsTruncated")); + if (!truncated) break; + + String next = extractTag(body, "NextMarker"); + if (isBlank(next) && !keys.isEmpty()) next = keys.get(keys.size() - 1); + if (isBlank(next)) break; // safety + marker = next; + } + } + } catch (Exception e) { + throw new CloudRuntimeException("ECS listBucketObjects failed: " + e.getMessage(), e); + } + return keys; + } + + // ---------------- delete bucket (Mgmt API) ---------------- + @Override + public boolean deleteBucket(final BucketTO bucket, final long storeId) { + final String bucketName = bucket.getName(); + + final Map ds = storeDetailsDao.getDetails(storeId); + final 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")); + + if (isBlank(mgmtUrl) || isBlank(saUser) || isBlank(saPass)) { + throw new CloudRuntimeException("ECS deleteBucket: missing mgmt_url/sa_user/sa_password for store"); + } + + // Fresh auth token + final String token = loginAndGetToken(mgmtUrl, saUser, saPass, insecure); + + // ECS delete = POST /object/bucket/{name}/deactivate?namespace={ns} + final String url = mgmtUrl + "/object/bucket/" + bucketName + "/deactivate?namespace=" + ns; + + try (CloseableHttpClient http = buildHttpClient(insecure)) { + final HttpPost post = new HttpPost(url); + post.setHeader("X-SDS-AUTH-TOKEN", token); + + try (CloseableHttpResponse r = http.execute(post)) { + final int st = r.getStatusLine().getStatusCode(); + final String body = r.getEntity() != null + ? EntityUtils.toString(r.getEntity(), StandardCharsets.UTF_8) + : ""; + + // Success + if (st == 200 || st == 204) { + logger.info("ECS bucket deactivated (deleted): '{}'", bucketName); + return true; + } + + // Non-empty bucket handling (examples: code 60019, phrases in body) + final String lb = body.toLowerCase(java.util.Locale.ROOT); + if (st == 400 || st == 409) { + if (lb.contains("not empty") || lb.contains("keypool not empty") || lb.contains("60019")) { + // Bubble explicit message to UI + throw new CloudRuntimeException("Cannot delete bucket '" + bucketName + "': bucket is not empty"); + } + } + + // Already gone? Treat 404 as success (idempotent delete) + if (st == 404) { + logger.info("ECS deleteBucket: '{}' not found; treating as already deleted.", bucketName); + return true; + } + + // Other errors -> surface with details + logger.error("ECS delete bucket '{}' failed: HTTP {} body={}", bucketName, st, body); + throw new CloudRuntimeException("Failed to delete ECS bucket '" + bucketName + "': HTTP " + st); + } + } catch (CloudRuntimeException cre) { + throw cre; // keep explicit messages (like "bucket is not empty") + } catch (Exception e) { + throw new CloudRuntimeException("Failed to delete ECS bucket '" + bucketName + "': " + e.getMessage(), e); + } + } + + @Override public AccessControlList getBucketAcl(final BucketTO bucket, final long storeId) { return null; } + @Override public void setBucketAcl(final BucketTO bucket, final AccessControlList acl, final long storeId) { /* not supported */ } + + // ---------------- Policy ---------------- + @Override + public void setBucketPolicy(final BucketTO bucket, final String policy, final long storeId) { + final String b = bucket.getName(); + + final Map ds = storeDetailsDao.getDetails(storeId); + final 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")); + + if (isBlank(mgmtUrl) || isBlank(saUser) || isBlank(saPass)) { + throw new CloudRuntimeException("ECS setBucketPolicy: missing mgmt_url/sa_user/sa_password for store"); + } + + final String token = loginAndGetToken(mgmtUrl, saUser, saPass, insecure); + final String url; + try { + url = mgmtUrl + "/object/bucket/" + b + "/policy?namespace=" + + java.net.URLEncoder.encode(ns, java.nio.charset.StandardCharsets.UTF_8.name()); + } catch (Exception e) { + throw new CloudRuntimeException("ECS setBucketPolicy: failed to encode namespace", e); + } + + // Normalize desired state + final String req = policy == null ? "" : policy.trim(); + final boolean wantPublic = + "public".equalsIgnoreCase(req) || "public-read".equalsIgnoreCase(req); + final boolean wantPrivate = + req.isEmpty() || "{}".equals(req) || "private".equalsIgnoreCase(req); + + if (!wantPublic && !wantPrivate && !req.startsWith("{")) { + throw new CloudRuntimeException("ECS setBucketPolicy: unsupported policy value '" + policy + + "'. Use 'public', 'private', or raw JSON."); + } + + // Query current state (helps us no-op when already desired) + final String current = getBucketPolicyRaw(url, token, insecure); // "" if none + final boolean hasPolicy = current != null && !current.isBlank(); + + try { + if (wantPrivate) { + // Make private by **deleting** the policy (PUT {} causes “Missing required field Statement” on ECS) + if (!hasPolicy) { + logger.info("ECS setBucketPolicy: already private (no policy). bucket='{}'", b); + return; + } + deleteBucketPolicyHttp(url, token, insecure); + logger.info("ECS setBucketPolicy: removed policy via DELETE. bucket='{}'", b); + return; + } + + // Public-read shortcut + if (wantPublic && hasPolicy) { + logger.info("ECS setBucketPolicy: policy already present; leaving as-is. bucket='{}'", b); + return; + } + + // Raw JSON provided or public-read template + 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" + + "}"); + + putBucketPolicy(url, token, policyJson, insecure); + logger.info("ECS setBucketPolicy: applied policy (bucket='{}').", b); + } catch (CloudRuntimeException cre) { + throw cre; + } catch (Exception e) { + throw new CloudRuntimeException("ECS setBucketPolicy error for bucket '" + b + "': " + e.getMessage(), e); + } + } + + @Override + public BucketPolicy getBucketPolicy(final BucketTO bucket, final long storeId) { + final String bucketName = bucket.getName(); + + final Map ds = storeDetailsDao.getDetails(storeId); + final 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")); + + if (isBlank(mgmtUrl) || isBlank(saUser) || isBlank(saPass)) { + throw new CloudRuntimeException("ECS getBucketPolicy: missing mgmt_url/sa_user/sa_password for store"); + } + + final String url; + try { + url = mgmtUrl + "/object/bucket/" + bucketName + "/policy?namespace=" + + java.net.URLEncoder.encode(ns, java.nio.charset.StandardCharsets.UTF_8.name()); + } catch (Exception e) { + throw new CloudRuntimeException("ECS getBucketPolicy: failed to encode namespace", e); + } + + final String token = loginAndGetToken(mgmtUrl, saUser, saPass, 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(); + final String body = resp.getEntity() == null ? "" : EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8).trim(); + + final BucketPolicy bp = new BucketPolicy(); + if (st == 200) { + // Normalize empty or "{}" to "{}" + bp.setPolicyText((body.isEmpty() || "{}".equals(body)) ? "{}" : body); + return bp; + } + if (st == 204 || st == 404 || ((st / 100) == 2 && body.isEmpty())) { + bp.setPolicyText("{}"); + return bp; + } + + throw new CloudRuntimeException("ECS getBucketPolicy failed: HTTP " + st + " body=" + body); + } + } catch (Exception e) { + throw new CloudRuntimeException("ECS getBucketPolicy error: " + e.getMessage(), e); + } + } + + @Override + public void deleteBucketPolicy(final BucketTO bucket, final long storeId) { + // ECS: make private by PUTting "{}" + setBucketPolicy(bucket, "{}", storeId); + } + + // --- Encryption (post-create): ECS cannot change it after creation --- + @Override + public boolean setBucketEncryption(final BucketTO bucket, final long storeId) { + final String bucketName = bucket != null ? bucket.getName() : ""; + + // We already applied the encryption choice at create time via + // in createBucket(). ECS does not support + // changing this afterwards, so any call here is treated as a no-op. + logger.info("ECS setBucketEncryption('{}'): ECS only supports encryption at bucket creation. " + + "Treating this call as a no-op (flag is only honored by createBucket()).", + bucketName); + + // DO NOT throw here, otherwise createBucket with encryption=true will fail + // when CloudStack calls this method as part of the create workflow. + return true; + } + + @Override + public boolean deleteBucketEncryption(final BucketTO bucket, final long storeId) { + final String bucketName = bucket != null ? bucket.getName() : ""; + final String msg = + "Dell ECS bucket encryption can only be chosen at bucket creation; " + + "it cannot be disabled afterwards (bucket=" + bucketName + ")"; + + logger.error("ECS deleteBucketEncryption('{}') requested but {}", bucketName, msg); + throw new CloudRuntimeException(msg); + } + + // --- Versioning (post-create) --- + + @Override + public boolean setBucketVersioning(final BucketTO bucket, final long storeId) { + // Enable S3 versioning on the bucket via helper + return setOrSuspendVersioning(bucket, storeId, true); + } + + @Override + public boolean deleteBucketVersioning(final BucketTO bucket, final long storeId) { + // Suspend S3 versioning on the bucket via helper + return setOrSuspendVersioning(bucket, storeId, false); + } + + private boolean setOrSuspendVersioning(final BucketTO bucket, final long storeId, final boolean enable) { + final Map ds = storeDetailsDao.getDetails(storeId); + final S3Endpoint ep = resolveS3Endpoint(ds, storeId); + final boolean insecure = "true".equalsIgnoreCase(ds.getOrDefault(INSECURE, "false")); + + if (ep == null || isBlank(ep.host)) { + logger.warn("ECS: {}BucketVersioning requested but S3 endpoint is not resolvable; skipping.", + enable ? "set" : "delete"); + return true; + } + + final String bucketName = bucket.getName(); + final String desired = enable ? "Enabled" : "Suspended"; + + // Retry loop: wait for DB row/keys to appear after create + final int maxTries = 45; // ~45s + for (int attempt = 1; attempt <= maxTries; attempt++) { + // 1) Prefer bucket-scoped keys from DB + BucketVO vo = resolveBucketVO(bucket, storeId); + if (vo == null) { + vo = findBucketVOByStoreAndName(storeId, bucketName); + if (vo == null) { + vo = findBucketVOAnyByName(bucketName); // last-chance scan if DAO exposes listAll + } + } + + 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()); + // fall through to try account keys / retry + } + } + + // 2) Account-scoped keys fallback + long accountId = -1L; + 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)); + + if (isBlank(accKey) || isBlank(secKey)) { + // Ensure keys exist + 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); + if (!isBlank(mgmtUrl) && !isBlank(saUser) && !isBlank(saPass)) { + final Account acct = accountDao.findById(accountId); + if (acct != null) { + final String ownerUser = "cs-" + acct.getUuid(); + try { + ensureAccountUserAndSecret(accountId, ownerUser, mgmtUrl, saUser, saPass, ns, 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 not last try, sleep and retry + if (attempt < maxTries) { + try { Thread.sleep(1000L); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } + } + } + + logger.warn("ECS: versioning {} for '{}' gave up after {} attempts; leaving as-is.", desired, bucket.getName(), 45); + return true; // absorb to avoid failing the job + } + + // ----- S3 Versioning (SigV2 path-style) ----- + private void setS3BucketVersioning(final CloseableHttpClient http, + final String scheme, + final String host, + final String bucketName, + final String accessKey, + final String secretKey, + final String status) throws Exception { + final String body = "" + status + ""; + final byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); + final String contentType = "application/xml"; + final String contentMd5 = base64Md5(bodyBytes); + final String dateHdr = rfc1123Now(); + + final String canonicalResource = "/" + bucketName + "?versioning"; + final String sts = "PUT\n" + contentMd5 + "\n" + contentType + "\n" + dateHdr + "\n" + canonicalResource; + final String signature = hmacSha1Base64(sts, secretKey); + + final String url = scheme + "://" + host + "/" + bucketName + "?versioning"; + final HttpPut put = new HttpPut(url); + put.setHeader("Host", host); + put.setHeader("Date", dateHdr); + put.setHeader("Authorization", "AWS " + accessKey + ":" + signature); + put.setHeader("Content-Type", contentType); + put.setHeader("Content-MD5", contentMd5); + put.setEntity(new StringEntity(body, StandardCharsets.UTF_8)); + + try (CloseableHttpResponse resp = http.execute(put)) { + final int st = resp.getStatusLine().getStatusCode(); + final String rb = resp.getEntity() != null ? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8) : ""; + if (st != 200 && st != 204) { + throw new CloudRuntimeException("S3 versioning " + status + " failed: HTTP " + st + " body=" + rb); + } + } + } + + /** + * Post-create quota changes (best-effort, never throws). + */ + @Override + public void setBucketQuota(final BucketTO bucket, final long storeId, final long size) { + if (size <= 0) { + logger.debug("ECS setBucketQuota ignored for {}: non-positive size {}", bucket.getName(), size); + return; + } + + final Map ds = storeDetailsDao.getDetails(storeId); + final 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")); + + if (isBlank(mgmtUrl) || isBlank(saUser) || isBlank(saPass)) { + logger.warn("ECS setBucketQuota skipped: missing mgmt_url/sa_user/sa_password for store"); + return; + } + + final String token = loginAndGetToken(mgmtUrl, saUser, saPass, insecure); + final String bucketName = bucket.getName(); + + try (CloseableHttpClient http = buildHttpClient(insecure)) { + Integer currentGb = null; + try { + final HttpGet get = new HttpGet(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 == 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"); + } + } + } catch (Exception e) { + logger.debug("ECS get quota for {} failed (non-fatal): {}", bucketName, e.getMessage()); + } + + if (currentGb != null && size <= currentGb) { + logger.info("ECS setBucketQuota noop for '{}': requested {}GB <= current {}GB", bucketName, size, currentGb); + return; + } + + final String quotaBody = + "" + + "" + size + "" + + "" + size + "" + + "" + ns + "" + + ""; + + final HttpPut put = new HttpPut(mgmtUrl + "/object/bucket/" + bucketName + "/quota"); + put.setHeader("X-SDS-AUTH-TOKEN", token); + put.setHeader("Content-Type", "application/xml"); + put.setEntity(new StringEntity(quotaBody, StandardCharsets.UTF_8)); + + try (CloseableHttpResponse r2 = http.execute(put)) { + final int st2 = r2.getStatusLine().getStatusCode(); + final String rb2 = r2.getEntity() != null ? EntityUtils.toString(r2.getEntity(), StandardCharsets.UTF_8) : ""; + if (st2 != 200 && st2 != 204) { + logger.warn("ECS set quota failed for {}: HTTP {} body={}. Ignoring.", bucketName, st2, rb2); + return; + } + } + + logger.info("ECS quota set for bucket='{}' newQuota={}GB", bucketName, size); + } catch (Exception e) { + logger.warn("ECS setBucketQuota encountered error for {}: {} (ignored)", bucketName, e.getMessage()); + } + } + + @Override + public Map getAllBucketsUsage(final long storeId) { + throw new CloudRuntimeException("Bucket usage aggregation is not implemented via Mgmt API in this plugin."); + } + + // ---------------- helpers ---------------- + + 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 (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(); + } + + private static String trimTail(final String s) { + if (s == null) return null; + return s.endsWith("/") ? s.substring(0, s.length() - 1) : s; + } + + private CloseableHttpClient buildHttpClient(final boolean insecure) { + if (!insecure) return HttpClients.createDefault(); + try { + final TrustStrategy trustAll = (chain, authType) -> true; + final SSLContext sslContext = SSLContextBuilder.create() + .loadTrustMaterial(null, trustAll) + .build(); + return HttpClients.custom() + .setSSLContext(sslContext) + .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE) + .build(); + } catch (Exception e) { + throw new CloudRuntimeException("Failed to build insecure HttpClient", e); + } + } + + private String loginAndGetToken(final String mgmtUrl, final String user, final String pass, final boolean insecure) { + try (CloseableHttpClient http = buildHttpClient(insecure)) { + final HttpGet get = new HttpGet(mgmtUrl + "/login"); + 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"); + } + return resp.getFirstHeader("X-SDS-AUTH-TOKEN").getValue(); + } + } catch (Exception e) { + throw new CloudRuntimeException("Failed to obtain ECS auth token: " + e.getMessage(), e); + } + } + + /** GET /object/user-secret-keys/{username} and parse any existing secrets. */ + private List fetchEcsUserSecrets(CloseableHttpClient http, + String mgmtUrl, String token, 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 == 200) { + final String xml = 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()); + return out; + } + return java.util.Collections.emptyList(); + } + } + + private void ensureAccountUserAndSecret(final long accountId, + final String username, + final String mgmtUrl, + final String saUser, + final String saPass, + final String ns, + final boolean insecure) { + final String haveAcc = valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_ACCESS)); + final String haveSec = valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_SECRET)); + + final String token = loginAndGetToken(mgmtUrl, saUser, saPass, insecure); + + try (CloseableHttpClient http = buildHttpClient(insecure)) { + // Ensure/CREATE user (idempotent) + final String createUserXml = + "" + + "" + username + "" + + "" + ns + "" + + "" + + ""; + + final HttpPost postUser = new HttpPost(mgmtUrl + "/object/users"); + postUser.setHeader("X-SDS-AUTH-TOKEN", token); + postUser.setHeader("Content-Type", "application/xml"); + postUser.setEntity(new StringEntity(createUserXml, StandardCharsets.UTF_8)); + try (CloseableHttpResponse r = http.execute(postUser)) { + final int st = r.getStatusLine().getStatusCode(); + final String rb = r.getEntity() != null ? EntityUtils.toString(r.getEntity(), StandardCharsets.UTF_8) : ""; + if (st == 200 || st == 201) { + logger.info("ECS user ensured/created for accountId={} -> {}", accountId, username); + } else if (st == 400 && rb != null && rb.contains("already exists")) { + logger.info("ECS user {} already exists (idempotent).", username); + } else { + logger.error("ECS user creation failed: status={} body={}", st, rb); + throw new CloudRuntimeException("ECS user creation failed: HTTP " + st); + } + } + + // If ACS already has key -> do NOT create another. + if (!isBlank(haveAcc) && !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); + if (ecsKeys.isEmpty()) { + final String skXml = + "" + + "" + ns + "" + + "" + haveSec + "" + + ""; + + final HttpPost postKey = new HttpPost(mgmtUrl + "/object/user-secret-keys/" + username); + postKey.setHeader("X-SDS-AUTH-TOKEN", token); + postKey.setHeader("Content-Type", "application/xml"); + postKey.setEntity(new StringEntity(skXml, StandardCharsets.UTF_8)); + try (CloseableHttpResponse kr = http.execute(postKey)) { + final int st = kr.getStatusLine().getStatusCode(); + final String rb = kr.getEntity() != null ? EntityUtils.toString(kr.getEntity(), StandardCharsets.UTF_8) : ""; + if (st == 200 || st == 201) { + logger.info("ECS secret reconciled for user {} (secret taken from ACS).", username); + } else if (st == 400 && rb != null && rb.contains("already has") && rb.contains("valid keys")) { + logger.info("ECS user {} already has valid secret(s); reconciliation not needed.", username); + } else { + logger.warn("ECS secret reconcile for {} returned HTTP {} body={} (continuing).", username, st, rb); + } + } + } + } catch (Exception e) { + logger.debug("ECS secret reconcile check skipped for {}: {}", username, e.getMessage()); + } + return; + } + + // ACS does NOT have key -> try to ADOPT existing ECS key first + try { + 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); + logger.info("Adopted existing ECS secret for user {} into ACS (no new key created).", username); + return; + } + } catch (Exception e) { + logger.debug("Failed to fetch existing ECS keys for {} (proceeding to create one): {}", username, e.getMessage()); + } + + // No ECS key either -> create exactly ONE new secret and store in ACS + final String newSecret = java.util.UUID.randomUUID().toString().replace("-", ""); + final String skXmlCreate = + "" + + "" + ns + "" + + "" + newSecret + "" + + ""; + + final HttpPost postKey2 = new HttpPost(mgmtUrl + "/object/user-secret-keys/" + username); + postKey2.setHeader("X-SDS-AUTH-TOKEN", token); + postKey2.setHeader("Content-Type", "application/xml"); + postKey2.setEntity(new StringEntity(skXmlCreate, StandardCharsets.UTF_8)); + try (CloseableHttpResponse kr2 = http.execute(postKey2)) { + final int st = kr2.getStatusLine().getStatusCode(); + final String rb = kr2.getEntity() != null ? EntityUtils.toString(kr2.getEntity(), StandardCharsets.UTF_8) : ""; + if (st != 200 && st != 201) { + if (st == 400 && rb != null && rb.contains("already has") && rb.contains("valid keys")) { + 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); + logger.info("Race: ECS already has key(s). Adopted existing secret for {} into ACS.", username); + return; + } + } + 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); + logger.info("ECS secret key created and stored for user={} (accountId={})", username, accountId); + + } catch (Exception e) { + throw new CloudRuntimeException("Failed to ensure/reconcile ECS user/secret: " + e.getMessage(), e); + } + } + + // ---------- Endpoint resolving for S3 (from s3_host) ---------- + + private static final class S3Endpoint { + final String scheme; // "http" or "https" + final String host; // hostname only + S3Endpoint(String scheme, String host) { this.scheme = scheme; this.host = host; } + } + + private S3Endpoint resolveS3Endpoint(final Map ds, final long storeId) { + String host = ds.get(S3_HOST); + String scheme = "https"; + + if (isBlank(host)) { + host = S3_HOST; + } + return new S3Endpoint(scheme, host); + } + + private String resolveS3HostForUI(final long storeId, final Map ds) { + String host = ds.get(S3_HOST); + if (isBlank(host)) host = S3_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; + + final String token = loginAndGetToken(mgmtUrl, saUser, saPass, insecure); + 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); + try { + Account acct = accountDao.findByUuid(uuid); + if (acct != null) return acct.getId(); + } catch (Throwable ignore) { } + } + } catch (Exception e) { + logger.debug("ECS: resolveAccountIdViaMgmt '{}' failed: {}", bucketName, e.getMessage()); + } + return null; + } + + private String fetchBucketOwnerViaMgmt(final CloseableHttpClient http, final String mgmtUrl, final String token, final String bucketName) throws Exception { + final HttpGet get = new HttpGet(mgmtUrl + "/object/bucket/" + bucketName); + get.setHeader("X-SDS-AUTH-TOKEN", token); + try (CloseableHttpResponse r = http.execute(get)) { + final int st = r.getStatusLine().getStatusCode(); + if (st == 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(); + } + return null; + } + } + + // ---------- Reflection helpers / VO lookups ---------- + + private static long getLongFromGetter(final Object o, final String getter, final long 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) { } + 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, 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) { + // Some branches expose listAll(); use it as a last-chance scan. + 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; + } + + // ---- lightweight XML/signing/time helpers (no extra deps) ---- + + /** 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(); + } + } + + private static String base64Md5(byte[] data) throws Exception { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5"); + byte[] digest = md.digest(data); + return Base64.getEncoder().encodeToString(digest); + } + + private static String hmacSha1Base64(String data, 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"); + mac.init(sk); + 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 + .ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US) + .withZone(java.time.ZoneOffset.UTC); + return fmt.format(java.time.Instant.now()); + } + + private static Boolean getOptionalBooleanFlag(final Object o, final String... methodNames) { + if (o == null) return null; + for (String m : methodNames) { + try { + Object v = o.getClass().getMethod(m).invoke(o); + if (v == null) continue; + if (v instanceof Boolean) return (Boolean) v; + if (v instanceof Number) return ((Number) v).intValue() != 0; + if (v instanceof String) { + final String s = ((String) v).trim(); + if (s.isEmpty()) continue; // treat empty as not-present + if ("true".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s) || "on".equalsIgnoreCase(s) || "1".equals(s)) return Boolean.TRUE; + if ("false".equalsIgnoreCase(s) || "no".equalsIgnoreCase(s) || "off".equalsIgnoreCase(s) || "0".equals(s)) return Boolean.FALSE; + } + } catch (NoSuchMethodException ignore) { + } catch (Exception ignore) { + } + } + return null; + } + + /** GET /policy raw body; returns "" if none (200 with empty/{} or 204/404). */ + private String getBucketPolicyRaw(String url, String token, 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(); + 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 (Exception e) { + throw new CloudRuntimeException("ECS getBucketPolicy error: " + e.getMessage(), e); + } + } + + /** PUT /policy with JSON. */ + private void putBucketPolicy(String url, String token, String policyJson, 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 == 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 (Exception e) { + throw new CloudRuntimeException("ECS setBucketPolicy error: " + e.getMessage(), e); + } + } + + /** DELETE /policy to make bucket private. */ + private void deleteBucketPolicyHttp(String url, String token, 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 == 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 (Exception e) { + throw new CloudRuntimeException("ECS deleteBucketPolicy error: " + e.getMessage(), e); + } + } + + /** Check if a bucket exists on ECS via Mgmt API /object/bucket/{name}/info?namespace=... */ + private boolean ecsBucketExists(final String bucketName, final Map ds) { + final String mgmtUrl = trimTail(ds.get(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")); + + if (isBlank(bucketName)) { + logger.warn("ecsBucketExists: bucket name is blank; treating as non-existent."); + return false; + } + + if (isBlank(mgmtUrl) || isBlank(saUser) || isBlank(saPass)) { + // If we cannot verify against ECS, be conservative and say it exists + // so that post-create encryption changes remain blocked. + logger.warn("ecsBucketExists('{}'): missing mgmt_url/sa_user/sa_password; assuming bucket exists.", + bucketName); + return true; + } + + final String token = loginAndGetToken(mgmtUrl, saUser, saPass, insecure); + + try (CloseableHttpClient http = buildHttpClient(insecure)) { + + final String url = mgmtUrl + + "/object/bucket/" + + java.net.URLEncoder.encode(bucketName, java.nio.charset.StandardCharsets.UTF_8.name()) + + "/info?namespace=" + + java.net.URLEncoder.encode(ns, java.nio.charset.StandardCharsets.UTF_8.name()); + + logger.debug("ecsBucketExists('{}'): GET {}", bucketName, url); + + final HttpGet get = new HttpGet(url); + get.setHeader("X-SDS-AUTH-TOKEN", token); + + try (CloseableHttpResponse resp = http.execute(get)) { + final int st = resp.getStatusLine().getStatusCode(); + final String body = resp.getEntity() != null + ? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8) + : ""; + + logger.debug("ecsBucketExists('{}'): HTTP {} body={}", bucketName, st, body); + + // 1) 200 => bucket exists + if (st == 200) { + logger.debug("ecsBucketExists('{}'): ECS returned 200 OK (bucket exists).", bucketName); + return true; + } + + // 2) 404 => bucket does NOT exist + if (st == 404) { + logger.debug("ecsBucketExists('{}'): ECS returned 404 Not Found (bucket does NOT exist).", + bucketName); + return false; + } + + // 3) 400 + code 1004 / “Unable to find entity with the given id …” => does NOT exist + if (st == 400) { + final String errCode = parseXmlTag(body, "code"); + final String errDetail = parseXmlTag(body, "details"); + final String errDesc = parseXmlTag(body, "description"); + + final String lowerBody = body == null ? "" : body.toLowerCase(java.util.Locale.ROOT); + final String lowerDetail = errDetail == null ? "" : errDetail.toLowerCase(java.util.Locale.ROOT); + final String lowerDesc = errDesc == null ? "" : errDesc.toLowerCase(java.util.Locale.ROOT); + + final boolean notFoundByCode = + "1004".equals(errCode); + final boolean notFoundByText = + lowerBody.contains("unable to find entity with the given id") + || lowerDetail.contains("unable to find entity with the given id") + || lowerDesc.contains("unable to find entity with the given id") + || lowerDesc.contains("request parameter cannot be found"); + + if (notFoundByCode || notFoundByText) { + logger.debug("ecsBucketExists('{}'): ECS 400/1004 'not found' (treating as NOT EXIST).", + bucketName); + return false; + } + } + + // 4) everything else => treat as exists (to be safe for post-create) + logger.warn("ecsBucketExists('{}'): unexpected HTTP {} body={}; treating as EXISTS.", + bucketName, st, body); + return true; + } + } 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/lifecycle/EcsObjectStoreLifeCycleImpl.java b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/EcsObjectStoreLifeCycleImpl.java new file mode 100644 index 000000000000..50cb8b13d528 --- /dev/null +++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/EcsObjectStoreLifeCycleImpl.java @@ -0,0 +1,255 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.cloudstack.storage.datastore.lifecycle; + +import java.util.HashMap; +import java.util.Map; + +import javax.inject.Inject; +import javax.net.ssl.SSLContext; + +import org.apache.cloudstack.engine.subsystem.api.storage.ClusterScope; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; +import org.apache.cloudstack.engine.subsystem.api.storage.HostScope; +import org.apache.cloudstack.engine.subsystem.api.storage.ZoneScope; +import org.apache.cloudstack.storage.datastore.db.ObjectStoreVO; +import org.apache.cloudstack.storage.object.datastore.ObjectStoreHelper; +import org.apache.cloudstack.storage.object.datastore.ObjectStoreProviderManager; +import org.apache.cloudstack.storage.object.store.lifecycle.ObjectStoreLifeCycle; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; // change to POST if ECS needs it +import org.apache.http.conn.ssl.NoopHostnameVerifier; +import org.apache.http.impl.auth.BasicScheme; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.ssl.SSLContextBuilder; +import org.apache.http.ssl.TrustStrategy; + +import com.cloud.agent.api.StoragePoolInfo; +import com.cloud.hypervisor.Hypervisor.HypervisorType; +import com.cloud.utils.exception.CloudRuntimeException; + +public class EcsObjectStoreLifeCycleImpl implements ObjectStoreLifeCycle { + + private static final Logger LOG = LogManager.getLogger(EcsObjectStoreLifeCycleImpl.class); + + // 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 + ObjectStoreHelper objectStoreHelper; + + @Inject + ObjectStoreProviderManager objectStoreMgr; + + public EcsObjectStoreLifeCycleImpl() { + } + + @SuppressWarnings("unchecked") + @Override + public DataStore initialize(Map dsInfos) { + if (objectStoreHelper == null) { + throw new CloudRuntimeException("ECS: ObjectStoreHelper is not injected"); + } + if (objectStoreMgr == null) { + throw new CloudRuntimeException("ECS: ObjectStoreProviderManager is not injected"); + } + + // Top-level params (follow Ceph pattern) + String url = (String) dsInfos.get("url"); + String name = (String) dsInfos.get("name"); + Long size = (Long) dsInfos.get("size"); // may be null + String providerName = (String) dsInfos.get("providerName"); // should be "ECS" + + Map details = (Map) dsInfos.get("details"); + if (details == null) { + throw new CloudRuntimeException("ECS: details map is missing"); + } + + // Validate required details + String mgmtUrl = trim(details.get(MGMT_URL)); + String saUser = safe(details.get(SA_USER)); + String saPass = safe(details.get(SA_PASS)); + boolean insecure = Boolean.parseBoolean(details.getOrDefault(INSECURE, "false")); + + if (mgmtUrl == null || mgmtUrl.isEmpty()) { + throw new CloudRuntimeException("ECS: missing required detail '" + MGMT_URL + "'"); + } + if (saUser.isEmpty()) { + throw new CloudRuntimeException("ECS: missing required detail '" + SA_USER + "'"); + } + if (saPass.isEmpty()) { + throw new CloudRuntimeException("ECS: missing required detail '" + SA_PASS + "'"); + } + + if (providerName == null || providerName.isEmpty()) { + providerName = PROVIDER_NAME; + } + + LOG.info("ECS initialize: provider='{}', name='{}', url='{}', mgmt_url='{}', insecure={}, s3_host='{}', namespace='{}'", + providerName, name, url, mgmtUrl, insecure, + details.get(S3_HOST), details.get(NAMESPACE)); + + // Try ECS login up-front so we fail fast on bad config + loginAndGetToken(mgmtUrl, saUser, saPass, insecure); + + // Put “canonical” values back into details (so DB keeps what we validated) + details.put(MGMT_URL, mgmtUrl); + details.put(SA_USER, saUser); + details.put(SA_PASS, saPass); + details.put(INSECURE, Boolean.toString(insecure)); + + // Build objectStore parameters exactly like Ceph does + Map objectStoreParameters = new HashMap<>(); + objectStoreParameters.put("name", name); + objectStoreParameters.put("url", url); + objectStoreParameters.put("size", size); + objectStoreParameters.put("providerName", providerName); + + try { + LOG.info("ECS: creating ObjectStore in DB: name='{}', provider='{}', url='{}'", + name, providerName, url); + ObjectStoreVO objectStore = objectStoreHelper.createObjectStore(objectStoreParameters, details); + if (objectStore == null) { + throw new CloudRuntimeException("ECS: createObjectStore returned null"); + } + + DataStore store = objectStoreMgr.getObjectStore(objectStore.getId()); + if (store == null) { + throw new CloudRuntimeException("ECS: getObjectStore returned null for id=" + objectStore.getId()); + } + + LOG.info("ECS: object store created: id={}, name='{}'", objectStore.getId(), name); + return store; + } catch (RuntimeException e) { + String msg = "ECS: failed to persist object store '" + name + "': " + safeMsg(e); + LOG.error(msg, e); + throw new CloudRuntimeException(msg, e); + } + } + + @Override + public boolean attachCluster(DataStore store, ClusterScope scope) { + return false; + } + + @Override + public boolean attachHost(DataStore store, HostScope scope, StoragePoolInfo existingInfo) { + return false; + } + + @Override + public boolean attachZone(DataStore dataStore, ZoneScope scope, HypervisorType hypervisorType) { + return false; + } + + @Override + public boolean maintain(DataStore store) { + return false; + } + + @Override + public boolean cancelMaintain(DataStore store) { + return false; + } + + @Override + public boolean deleteDataStore(DataStore store) { + return false; + } + + @Override + public boolean migrateToObjectStore(DataStore store) { + return false; + } + + // ---------- helpers ---------- + + private static String safe(String v) { + return v == null ? "" : v.trim(); + } + + private static String trim(String v) { + if (v == null) { + return null; + } + v = v.trim(); + return v.endsWith("/") ? v.substring(0, v.length() - 1) : v; + } + + private static String safeMsg(Throwable t) { + if (t == null) { + return "unknown"; + } + String m = t.getMessage(); + return (m == null || m.isEmpty()) ? t.getClass().getSimpleName() : m; + } + + private CloseableHttpClient buildHttpClient(boolean insecure) { + if (!insecure) { + return HttpClients.createDefault(); + } + try { + TrustStrategy trustAll = (chain, authType) -> true; + SSLContext sslContext = SSLContextBuilder.create() + .loadTrustMaterial(null, trustAll) + .build(); + return HttpClients.custom() + .setSSLContext(sslContext) + .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE) + .build(); + } catch (Exception e) { + throw new CloudRuntimeException("ECS: failed to build HttpClient", e); + } + } + + private String loginAndGetToken(String mgmtUrl, String user, String pass, boolean insecure) { + try (CloseableHttpClient http = buildHttpClient(insecure)) { + HttpGet get = new HttpGet(mgmtUrl + "/login"); + get.addHeader(new BasicScheme().authenticate( + new UsernamePasswordCredentials(user, pass), get, null)); + try (CloseableHttpResponse resp = http.execute(get)) { + int status = resp.getStatusLine().getStatusCode(); + if (status != 200 && status != 201) { + throw new CloudRuntimeException("ECS /login failed: HTTP " + status); + } + if (resp.getFirstHeader("X-SDS-AUTH-TOKEN") == null) { + throw new CloudRuntimeException("ECS /login missing X-SDS-AUTH-TOKEN"); + } + return resp.getFirstHeader("X-SDS-AUTH-TOKEN").getValue(); + } + } catch (Exception e) { + String msg = "ECS: management login error: " + safeMsg(e); + LOG.error(msg, e); + throw new CloudRuntimeException(msg, e); + } + } +} diff --git a/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/provider/EcsObjectStoreProviderImpl.java b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/provider/EcsObjectStoreProviderImpl.java new file mode 100644 index 000000000000..4661ed0ebe17 --- /dev/null +++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/provider/EcsObjectStoreProviderImpl.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.cloudstack.storage.datastore.provider; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import javax.inject.Inject; + +import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreDriver; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreLifeCycle; +import org.apache.cloudstack.engine.subsystem.api.storage.HypervisorHostListener; +import org.apache.cloudstack.engine.subsystem.api.storage.ObjectStoreProvider; +import org.apache.cloudstack.storage.datastore.driver.EcsObjectStoreDriverImpl; +import org.apache.cloudstack.storage.datastore.lifecycle.EcsObjectStoreLifeCycleImpl; +import org.apache.cloudstack.storage.object.ObjectStoreDriver; +import org.apache.cloudstack.storage.object.datastore.ObjectStoreHelper; +import org.apache.cloudstack.storage.object.datastore.ObjectStoreProviderManager; +import org.apache.cloudstack.storage.object.store.lifecycle.ObjectStoreLifeCycle; +import org.springframework.stereotype.Component; + +import com.cloud.utils.component.ComponentContext; + +@Component +public class EcsObjectStoreProviderImpl implements ObjectStoreProvider { + + @Inject + ObjectStoreProviderManager storeMgr; + + @Inject + ObjectStoreHelper helper; + + private final String providerName = "ECS"; + + protected ObjectStoreLifeCycle lifeCycle; + protected ObjectStoreDriver driver; + + @Override + public String getName() { + return providerName; + } + + @Override + public boolean configure(Map params) { + // Follow Ceph provider pattern + lifeCycle = ComponentContext.inject(EcsObjectStoreLifeCycleImpl.class); + driver = ComponentContext.inject(EcsObjectStoreDriverImpl.class); + storeMgr.registerDriver(getName(), driver); + return true; + } + + @Override + public DataStoreLifeCycle getDataStoreLifeCycle() { + return lifeCycle; + } + + @Override + public DataStoreDriver getDataStoreDriver() { + return driver; + } + + @Override + public HypervisorHostListener getHostListener() { + return null; + } + + @Override + public Set getTypes() { + Set types = new HashSet<>(); + types.add(DataStoreProviderType.OBJECT); + return types; + } +} diff --git a/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/module.properties b/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/module.properties new file mode 100644 index 000000000000..8bd3e198ed3d --- /dev/null +++ b/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/module.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +name=storage-object-ecs +parent=storage \ No newline at end of file diff --git a/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/spring-storage-object-ecs-context.xml b/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/spring-storage-object-ecs-context.xml new file mode 100644 index 000000000000..bb149e6e86a1 --- /dev/null +++ b/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/spring-storage-object-ecs-context.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/ui/src/views/infra/AddObjectStorage.vue b/ui/src/views/infra/AddObjectStorage.vue index 5410a9b9502f..f9240b930c46 100644 --- a/ui/src/views/infra/AddObjectStorage.vue +++ b/ui/src/views/infra/AddObjectStorage.vue @@ -31,6 +31,7 @@ +