diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 689275cff115..847b7632f32b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -18,6 +18,7 @@ /plugins/storage/volume/linstor @rp- /plugins/storage/volume/storpool @slavkap /plugins/storage/volume/ontap @rajiv1 @sandeeplocharla @piyush5 @suryag +/plugins/storage/object/ecs @mhkadhum .pre-commit-config.yaml @jbampton /.github/linters/ @jbampton diff --git a/client/pom.xml b/client/pom.xml index 7118f455ab5f..9961d68ac594 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -637,6 +637,11 @@ cloud-plugin-storage-object-minio ${project.version} + + org.apache.cloudstack + cloud-plugin-storage-object-ecs + ${project.version} + org.apache.cloudstack cloud-plugin-storage-object-ceph diff --git a/plugins/pom.xml b/plugins/pom.xml index e4904ccdf40b..8d9277e04417 100755 --- a/plugins/pom.xml +++ b/plugins/pom.xml @@ -141,6 +141,7 @@ storage/object/ceph storage/object/cloudian storage/object/simulator + storage/object/ecs storage-allocators/random diff --git a/plugins/storage/object/ECS/pom copy.xml b/plugins/storage/object/ECS/pom copy.xml new file mode 100644 index 000000000000..c19ce1698aa9 --- /dev/null +++ b/plugins/storage/object/ECS/pom copy.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/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/EcsCfg.java b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsCfg.java new file mode 100644 index 000000000000..b18e759da18f --- /dev/null +++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsCfg.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.cloudstack.storage.datastore.driver; + +/** Immutable ECS connection configuration shared by the driver and lifecycle. */ +public final class EcsCfg { + public final String mgmtUrl; + public final String saUser; + public final String saPass; + public final String ns; + public final boolean insecure; + + public EcsCfg(final String mgmtUrl, final String saUser, final String saPass, + final String ns, final boolean insecure) { + this.mgmtUrl = mgmtUrl; + this.saUser = saUser; + this.saPass = saPass; + this.ns = ns; + this.insecure = insecure; + } +} diff --git a/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsConstants.java b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsConstants.java new file mode 100644 index 000000000000..508918a2450e --- /dev/null +++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsConstants.java @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.cloudstack.storage.datastore.driver; + +/** Object-store detail keys and defaults shared across the ECS plugin. */ +public final class EcsConstants { + + private EcsConstants() { } + + // Object-store detail 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"; + + // Per-account credential keys (stored in account_details) + public static final String AD_KEY_ACCESS = "ecs.accesskey"; + public static final String AD_KEY_SECRET = "ecs.secretkey"; + + // Defaults + public static final String DEFAULT_USER_PREFIX = "cs-"; +} 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..7127745e67f0 --- /dev/null +++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsObjectStoreDriverImpl.java @@ -0,0 +1,1157 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.cloudstack.storage.datastore.driver; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import javax.inject.Inject; + +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.commons.lang3.StringUtils; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.auth.BasicScheme; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.util.EntityUtils; +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.storage.BucketVO; +import com.cloud.storage.dao.BucketDao; +import com.cloud.user.Account; +import com.cloud.user.AccountDetailsDao; +import com.cloud.user.dao.AccountDao; +import com.cloud.utils.exception.CloudRuntimeException; + +public class EcsObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { + private static final Logger logger = LogManager.getLogger(EcsObjectStoreDriverImpl.class); + + private static final String MGMT_URL = EcsConstants.MGMT_URL; + private static final String SA_USER = EcsConstants.SA_USER; + private static final String SA_PASS = EcsConstants.SA_PASS; + private static final String NAMESPACE = EcsConstants.NAMESPACE; + private static final String INSECURE = EcsConstants.INSECURE; + private static final String S3_HOST = EcsConstants.S3_HOST; + private static final String AD_KEY_ACCESS = EcsConstants.AD_KEY_ACCESS; + private static final String AD_KEY_SECRET = EcsConstants.AD_KEY_SECRET; + + // ---- ECS token caching ---- + 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<>(); + + 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; } + } + + @FunctionalInterface + private interface WithToken { T run(String token) throws Exception; } + + @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; } + + // ---- helpers: config ---- + + private EcsCfg ecsCfgFromDetails(final Map ds, final long storeId) { + final String mgmtUrl = EcsUtils.trimTail(ds.get(MGMT_URL)); + final String saUser = ds.get(SA_USER); + final String saPass = ds.get(SA_PASS); + final String ns = StringUtils.defaultIfBlank(ds.get(NAMESPACE), "default"); + final boolean insecure = "true".equalsIgnoreCase(ds.getOrDefault(INSECURE, "false")); + + if (EcsUtils.isBlank(mgmtUrl) || EcsUtils.isBlank(saUser) || EcsUtils.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 EcsCfg ecsCfgFromStore(final long storeId) { + return ecsCfgFromDetails(storeDetailsDao.getDetails(storeId), storeId); + } + + // ---- helpers: token ---- + + 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 void invalidateToken(final String mgmtUrl, final String user) { + TOKEN_CACHE.remove(new TokenKey(EcsUtils.trimTail(mgmtUrl), user)); + } + + private String getAuthToken(final String mgmtUrl, final String user, final String pass, final boolean insecure) { + final String mu = EcsUtils.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 = EcsUtils.buildHttpClient(insecure)) { + final HttpGet get = new HttpGet(mgmtUrl + "/login"); + get.addHeader(new BasicScheme().authenticate(new UsernamePasswordCredentials(user, pass), get, null)); + try (CloseableHttpResponse resp = http.execute(get)) { + final int status = resp.getStatusLine().getStatusCode(); + if (status != 200 && status != 201) { + throw new CloudRuntimeException("ECS /login failed: HTTP " + status); + } + if (resp.getFirstHeader("X-SDS-AUTH-TOKEN") == null) { + throw new CloudRuntimeException("ECS /login 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); + logger.debug("ECS token fetched for user='{}' (maxAge={}s, effective={}s)", user, maxAgeSec, effectiveSec); + return new TokenEntry(token, System.currentTimeMillis() + (effectiveSec * 1000L)); + } + } catch (Exception e) { + throw new CloudRuntimeException("Failed to obtain ECS auth token: " + e.getMessage(), e); + } + } + + // ---- create bucket ---- + + @Override + public Bucket createBucket(final Bucket bucket, final boolean objectLock) { + if (objectLock) { + throw new CloudRuntimeException("Dell ECS doesn't support this feature: object locking"); + } + + final long storeId = bucket.getObjectStoreId(); + final String name = bucket.getName(); + final Map ds = storeDetailsDao.getDetails(storeId); + final EcsCfg cfg = ecsCfgFromDetails(ds, storeId); + + final BucketVO vo = bucketDao.findById(bucket.getId()); + final Account acct = accountDao.findById(vo.getAccountId()); + if (acct == null) { + throw new CloudRuntimeException("ECS createBucket: account not found: id=" + vo.getAccountId()); + } + final String ownerUser = getUserPrefix(ds) + acct.getUuid(); + + ensureAccountUserAndSecret(vo.getAccountId(), ownerUser, cfg); + + Integer quotaGb = safeIntFromGetter(bucket, "getQuota"); + if (quotaGb == null) quotaGb = safeIntFromGetter(bucket, "getSize"); + final int blockSizeGb = (quotaGb != null && quotaGb > 0) ? quotaGb : 2; + final int notifSizeGb = (quotaGb != null && quotaGb > 0) ? quotaGb : 1; + + 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" + + "" + cfg.ns + "" + + "" + ownerUser + "" + + "" + encryptionEnabled + "" + + ""; + + logger.debug("ECS createBucket XML for '{}': {}", name, createBody); + + try { + mgmtCallWithRetry401(cfg, token -> doCreateBucket(cfg, token, name, createBody)); + + final String s3Host = resolveS3HostForUI(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); + + final String accKey = EcsUtils.valueOrNull(accountDetailsDao.findDetail(vo.getAccountId(), AD_KEY_ACCESS)); + final String secKey = EcsUtils.valueOrNull(accountDetailsDao.findDetail(vo.getAccountId(), AD_KEY_SECRET)); + if (vo != null) { + vo.setBucketURL(s3UrlForUI); + if (!EcsUtils.isBlank(accKey)) vo.setAccessKey(accKey); + if (!EcsUtils.isBlank(secKey)) vo.setSecretKey(secKey); + bucketDao.update(vo.getId(), vo); + } + return bucket; + } catch (CloudRuntimeException e) { + throw e; + } catch (Exception e) { + throw new CloudRuntimeException("Failed to create ECS bucket " + name + ": " + e.getMessage(), e); + } + } + + private Void doCreateBucket(final EcsCfg cfg, final String token, final String name, final String createBody) throws Exception { + try (CloseableHttpClient http = EcsUtils.buildHttpClient(cfg.insecure)) { + final HttpPost post = new HttpPost(cfg.mgmtUrl + "/object/bucket"); + post.setHeader("X-SDS-AUTH-TOKEN", token); + post.setHeader("Content-Type", "application/xml"); + post.setEntity(new StringEntity(createBody, StandardCharsets.UTF_8)); + try (CloseableHttpResponse resp = http.execute(post)) { + final int status = resp.getStatusLine().getStatusCode(); + final String body = resp.getEntity() != null + ? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8) : ""; + if (status == 401) throw new EcsUnauthorizedException("ECS createBucket got 401"); + if (status != 200 && status != 201) { + String reason = "HTTP " + status; + if (status == 400) { + if (EcsXmlParser.looksLikeBucketAlreadyExists400(body)) { + reason = "HTTP 400 bucket name already exists"; + } + } + logger.error("ECS create bucket failed: {} body={}", reason, body); + throw new CloudRuntimeException("Failed to create ECS bucket " + name + ": " + reason); + } + } + } + return null; + } + + @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); + ensureAccountUserAndSecret(accountId, getUserPrefix(ds) + acct.getUuid(), ecsCfgFromDetails(ds, storeId)); + return true; + } + + // ---- list buckets (S3 SigV2) ---- + + @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 = EcsUtils.valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_ACCESS)); + final String secretKey = EcsUtils.valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_SECRET)); + if (EcsUtils.isBlank(accessKey) || EcsUtils.isBlank(secretKey)) { + throw new CloudRuntimeException("ECS listBuckets: account has no stored S3 credentials"); + } + + final S3Endpoint ep = resolveS3Endpoint(ds); + if (ep == null || EcsUtils.isBlank(ep.host)) { + throw new CloudRuntimeException("ECS listBuckets: S3 endpoint not resolvable"); + } + + final String ns = StringUtils.defaultIfBlank(ds.get(NAMESPACE), "default"); + final List out = new ArrayList<>(); + try (CloseableHttpClient http = EcsUtils.buildHttpClient("true".equalsIgnoreCase(ds.getOrDefault(INSECURE, "false")))) { + final String dateHdr = rfc1123Now(); + final String emcNsHeader = "x-emc-namespace:" + ns + "\n"; + final String sts = "GET\n\n\n" + dateHdr + "\n" + emcNsHeader + "/"; + final String signature = hmacSha1Base64(sts, secretKey); + + final HttpGet get = new HttpGet(ep.scheme + "://" + ep.host + "/"); + get.setHeader("Host", ep.host); + get.setHeader("Date", dateHdr); + get.setHeader("Authorization", "AWS " + accessKey + ":" + signature); + get.setHeader("x-emc-namespace", ns); + + 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); + } + for (String n : extractAllTags(body, "Name")) { + if (EcsUtils.isBlank(n)) continue; + final Bucket b = new BucketObject(); + b.setName(n.trim()); + out.add(b); + } + } + } catch (Exception e) { + throw new CloudRuntimeException("ECS listBuckets failed: " + e.getMessage(), e); + } + return out; + } + + // ---- delete bucket ---- + + @Override + public boolean deleteBucket(final BucketTO bucket, final long storeId) { + final String bucketName = bucket.getName(); + final EcsCfg cfg = ecsCfgFromStore(storeId); + final String url = cfg.mgmtUrl + "/object/bucket/" + bucketName + "/deactivate?namespace=" + cfg.ns; + + try { + return mgmtCallWithRetry401(cfg, token -> doDeleteBucket(cfg, token, url, bucketName)); + } catch (CloudRuntimeException cre) { + throw cre; + } catch (Exception e) { + throw new CloudRuntimeException("Failed to delete ECS bucket '" + bucketName + "': " + e.getMessage(), e); + } + } + + private boolean doDeleteBucket(final EcsCfg cfg, final String token, final String url, final String bucketName) throws Exception { + try (CloseableHttpClient http = EcsUtils.buildHttpClient(cfg.insecure)) { + final HttpPost post = new HttpPost(url); + post.setHeader("X-SDS-AUTH-TOKEN", token); + try (CloseableHttpResponse r = http.execute(post)) { + final int st = r.getStatusLine().getStatusCode(); + final String body = r.getEntity() != null + ? EntityUtils.toString(r.getEntity(), StandardCharsets.UTF_8) : ""; + if (st == 401) throw new EcsUnauthorizedException("ECS deleteBucket got 401"); + if (st == 200 || st == 204) { + logger.info("ECS bucket deactivated (deleted): '{}'", bucketName); + return true; + } + if (st == 400 || st == 409) { + final String lb = body.toLowerCase(Locale.ROOT); + if (lb.contains("not empty") || lb.contains("keypool not empty") || lb.contains("60019")) { + throw new CloudRuntimeException("Cannot delete bucket '" + bucketName + "': bucket is not empty"); + } + } + if (st == 404) { + logger.info("ECS deleteBucket: '{}' not found; treating as already deleted.", bucketName); + return true; + } + logger.error("ECS delete bucket '{}' failed: HTTP {} body={}", bucketName, st, body); + throw new CloudRuntimeException("Failed to delete ECS bucket '" + bucketName + "': HTTP " + st); + } + } + } + + // ---- ACL (unsupported) ---- + + @Override + public AccessControlList getBucketAcl(final BucketTO bucket, final long storeId) { + throw new UnsupportedOperationException("ECS: getBucketAcl is not supported"); + } + + @Override + public void setBucketAcl(final BucketTO bucket, final AccessControlList acl, final long storeId) { + throw new UnsupportedOperationException("ECS: setBucketAcl is not supported"); + } + + // ---- Policy ---- + + @Override + public void setBucketPolicy(final BucketTO bucket, final String policy, final long storeId) { + final String b = bucket.getName(); + final EcsCfg cfg = ecsCfgFromStore(storeId); + final String url = buildPolicyUrl(cfg, b); + final String req = policy == null ? "" : policy.trim(); + final boolean wantPublic = "public".equalsIgnoreCase(req) || "public-read".equalsIgnoreCase(req); + final boolean wantPrivate = req.isEmpty() || "{}".equals(req) || "private".equalsIgnoreCase(req); + + if (!wantPublic && !wantPrivate && !req.startsWith("{")) { + throw new CloudRuntimeException("ECS setBucketPolicy: unsupported policy value '" + policy + + "'. Use 'public', 'private', or raw JSON."); + } + try { + mgmtCallWithRetry401(cfg, token -> doSetBucketPolicy(cfg, token, url, b, req, wantPublic, wantPrivate)); + } catch (CloudRuntimeException cre) { + throw cre; + } catch (Exception e) { + throw new CloudRuntimeException("ECS setBucketPolicy error for bucket '" + b + "': " + e.getMessage(), e); + } + } + + private Void doSetBucketPolicy(final EcsCfg cfg, final String token, final String url, + final String b, final String req, final boolean wantPublic, final boolean wantPrivate) throws Exception { + final String current = getBucketPolicyRaw(url, token, cfg.insecure); + final boolean hasPolicy = !current.isBlank(); + + if (wantPrivate) { + if (!hasPolicy) { + logger.info("ECS setBucketPolicy: already private (no policy). bucket='{}'", b); + return null; + } + deleteBucketPolicyHttp(url, token, cfg.insecure); + logger.info("ECS setBucketPolicy: removed policy via DELETE. bucket='{}'", b); + return null; + } + if (wantPublic && hasPolicy) { + logger.info("ECS setBucketPolicy: policy already present; leaving as-is. bucket='{}'", b); + return null; + } + final String policyJson = 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, cfg.insecure); + logger.info("ECS setBucketPolicy: applied policy (bucket='{}').", b); + return null; + } + + @Override + public BucketPolicy getBucketPolicy(final BucketTO bucket, final long storeId) { + final String bucketName = bucket.getName(); + final EcsCfg cfg = ecsCfgFromStore(storeId); + final String url = buildPolicyUrl(cfg, bucketName); + try { + return mgmtCallWithRetry401(cfg, token -> doGetBucketPolicy(cfg, token, url)); + } catch (Exception e) { + if (e instanceof CloudRuntimeException) throw (CloudRuntimeException) e; + throw new CloudRuntimeException("ECS getBucketPolicy error: " + e.getMessage(), e); + } + } + + private BucketPolicy doGetBucketPolicy(final EcsCfg cfg, final String token, final String url) throws Exception { + try (CloseableHttpClient http = EcsUtils.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"); + final String body = resp.getEntity() == null ? "" : + EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8).trim(); + final BucketPolicy bp = new BucketPolicy(); + if (st == 200) { + bp.setPolicyText((body.isEmpty() || "{}".equals(body)) ? "{}" : body); + return bp; + } + if (st == 204 || st == 404 || ((st / 100) == 2 && body.isEmpty())) { + bp.setPolicyText("{}"); + return bp; + } + throw new CloudRuntimeException("ECS getBucketPolicy failed: HTTP " + st + " body=" + body); + } + } + } + + @Override + public void deleteBucketPolicy(final BucketTO bucket, final long storeId) { + setBucketPolicy(bucket, "{}", storeId); + } + + // ---- Encryption ---- + + @Override + public boolean setBucketEncryption(final BucketTO bucket, final long storeId) { + // Encryption is set at bucket creation time in the createBucket XML body. + // CloudStack calls this method as a post-creation step; treat it as a no-op. + logger.debug("ECS setBucketEncryption('{}') called after creation — no-op (encryption is set at creation time).", + bucket != null ? bucket.getName() : ""); + return true; + } + + @Override + public boolean deleteBucketEncryption(final BucketTO bucket, final long storeId) { + throw new UnsupportedOperationException( + "ECS: bucket encryption cannot be disabled after creation"); + } + + // ---- Versioning (S3 SigV2) ---- + + @Override + public boolean setBucketVersioning(final BucketTO bucket, final long storeId) { + return setOrSuspendVersioning(bucket, storeId, true); + } + + @Override + public boolean deleteBucketVersioning(final BucketTO bucket, final long storeId) { + return setOrSuspendVersioning(bucket, storeId, false); + } + + private boolean setOrSuspendVersioning(final BucketTO bucket, final long storeId, final boolean enable) { + final Map ds = storeDetailsDao.getDetails(storeId); + final S3Endpoint ep = resolveS3Endpoint(ds); + if (ep == null || EcsUtils.isBlank(ep.host)) { + throw new CloudRuntimeException("ECS: S3 endpoint is not configured; cannot " + + (enable ? "enable" : "disable") + " versioning for bucket '" + bucket.getName() + "'"); + } + + final boolean insecure = "true".equalsIgnoreCase(ds.getOrDefault(INSECURE, "false")); + final String bucketName = bucket.getName(); + final String desired = enable ? "Enabled" : "Suspended"; + + // Prefer the calling account; fall back to the bucket owner record. + long accountId = -1L; + final CallContext ctx = CallContext.current(); + if (ctx != null && ctx.getCallingAccount() != null) { + accountId = ctx.getCallingAccount().getId(); + } + if (accountId <= 0) { + accountId = resolveBucketOwnerAccountId(bucket, storeId, bucketName); + } + if (accountId <= 0) { + throw new CloudRuntimeException("ECS: cannot determine account for bucket '" + + bucketName + "'; unable to set versioning"); + } + + final String accessKey = EcsUtils.valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_ACCESS)); + final String secretKey = EcsUtils.valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_SECRET)); + if (EcsUtils.isBlank(accessKey) || EcsUtils.isBlank(secretKey)) { + throw new CloudRuntimeException("ECS: no S3 credentials for account " + accountId + + "; cannot set versioning for bucket '" + bucketName + "'"); + } + + try (CloseableHttpClient http = EcsUtils.buildHttpClient(insecure)) { + putBucketVersioningSigV2(http, ep.scheme, ep.host, bucketName, accessKey, secretKey, desired); + logger.info("ECS: versioning {} applied for bucket='{}'.", desired, bucketName); + return true; + } catch (Exception e) { + // Best-effort: log but do not fail bucket creation. + // ECS may deny S3 versioning if the object user lacks namespace-level versioning rights. + logger.warn("ECS: versioning {} failed for '{}': {} — bucket created without versioning.", + desired, bucketName, e.getMessage()); + return true; + } + } + + private void putBucketVersioningSigV2(final CloseableHttpClient http, + final String scheme, final String host, + final String bucketName, + final String accessKey, final String secretKey, + final String status) throws Exception { + // Plain XML — no xmlns attribute (matches what ECS S3 endpoint expects) + 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 stringToSign = "PUT\n" + contentMd5 + "\n" + contentType + "\n" + dateHdr + "\n" + canonicalResource; + final String signature = hmacSha1Base64(stringToSign, secretKey); + + final HttpPut put = new HttpPut(scheme + "://" + host + "/" + bucketName + "?versioning"); + put.setHeader("Date", dateHdr); + put.setHeader("Content-Type", contentType); + put.setHeader("Content-MD5", contentMd5); + put.setHeader("Authorization", "AWS " + accessKey + ":" + signature); + put.setEntity(new StringEntity(body, StandardCharsets.UTF_8)); + + try (CloseableHttpResponse resp = http.execute(put)) { + final int st = resp.getStatusLine().getStatusCode(); + if (st != 200 && st != 204) { + final String rb = resp.getEntity() != null + ? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8) : ""; + throw new CloudRuntimeException("S3 versioning " + status + " failed: HTTP " + st + " body=" + rb); + } + } + } + + private long resolveBucketOwnerAccountId(final BucketTO bucket, final long storeId, final String bucketName) { + // Always use the bucket record's owner, not the calling context. + final BucketVO vo = resolveBucketVO(bucket, storeId); + if (vo != null) { + try { return vo.getAccountId(); } catch (Throwable ignore) { } + } + final BucketVO voByName = findBucketVOByName(bucketName, storeId); + if (voByName != null) { + try { return voByName.getAccountId(); } catch (Throwable ignore) { } + } + return -1L; + } + + private void setS3BucketVersioning(final CloseableHttpClient http, final String scheme, final String host, + final String bucketName, final String accessKey, final String secretKey, + final String status, final String namespace) 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 emcNsHeader = EcsUtils.isBlank(namespace) ? "" : "x-emc-namespace:" + namespace + "\n"; + final String sts = "PUT\n" + contentMd5 + "\n" + contentType + "\n" + dateHdr + "\n" + emcNsHeader + canonicalResource; + final String signature = hmacSha1Base64(sts, secretKey); + + final HttpPut put = new HttpPut(scheme + "://" + host + "/" + bucketName + "?versioning"); + put.setHeader("Host", host); + put.setHeader("Date", dateHdr); + put.setHeader("Authorization", "AWS " + accessKey + ":" + signature); + put.setHeader("Content-Type", contentType); + put.setHeader("Content-MD5", contentMd5); + if (!EcsUtils.isBlank(namespace)) put.setHeader("x-emc-namespace", namespace); + put.setEntity(new StringEntity(body, StandardCharsets.UTF_8)); + + try (CloseableHttpResponse resp = http.execute(put)) { + final int st = resp.getStatusLine().getStatusCode(); + if (st != 200 && st != 204) { + final String rb = resp.getEntity() != null ? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8) : ""; + throw new CloudRuntimeException("S3 versioning " + status + " failed: HTTP " + st + " body=" + rb); + } + } + } + + // ---- Quota ---- + + @Override + public void setBucketQuota(final BucketTO bucket, final long storeId, final long size) { + if (size <= 0) { + logger.debug("ECS setBucketQuota ignored for {}: non-positive size {}", bucket.getName(), size); + return; + } + final EcsCfg cfg = ecsCfgFromStore(storeId); + final String bucketName = bucket.getName(); + try { + mgmtCallWithRetry401(cfg, token -> doSetBucketQuota(cfg, token, bucketName, size)); + } catch (Exception e) { + logger.warn("ECS setBucketQuota encountered error for {}: {} (ignored)", bucketName, e.getMessage()); + } + } + + private Void doSetBucketQuota(final EcsCfg cfg, final String token, + final String bucketName, final long size) throws Exception { + try (CloseableHttpClient http = EcsUtils.buildHttpClient(cfg.insecure)) { + final Integer currentGb = fetchCurrentQuota(http, cfg, token, bucketName); + if (currentGb != null && size <= currentGb) { + throw new CloudRuntimeException("ECS setBucketQuota: cannot reduce quota for '" + bucketName + + "' from " + currentGb + "GB to " + size + "GB (ECS only supports increasing quota)"); + } + + final String quotaBody = + "" + + "" + size + "" + + "" + size + "" + + "" + cfg.ns + "" + + ""; + + final HttpPut put = new HttpPut(cfg.mgmtUrl + "/object/bucket/" + bucketName + "/quota"); + put.setHeader("X-SDS-AUTH-TOKEN", token); + put.setHeader("Content-Type", "application/xml"); + put.setEntity(new StringEntity(quotaBody, StandardCharsets.UTF_8)); + + try (CloseableHttpResponse r = http.execute(put)) { + 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 set quota got 401"); + if (st != 200 && st != 204) { + logger.warn("ECS set quota failed for {}: HTTP {} body={}. Ignoring.", bucketName, st, rb); + } + } + logger.info("ECS quota set for bucket='{}' newQuota={}GB", bucketName, size); + return null; + } + } + + private Integer fetchCurrentQuota(final CloseableHttpClient http, final EcsCfg cfg, + final String token, final String bucketName) { + 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 == 200) { + final String xml = r.getEntity() != null ? EntityUtils.toString(r.getEntity(), StandardCharsets.UTF_8) : ""; + Integer gb = parseIntTag(xml, "blockSize"); + return gb != null ? gb : parseIntTag(xml, "notificationSize"); + } + } + } catch (EcsUnauthorizedException u) { + throw u; + } catch (Exception e) { + logger.debug("ECS get quota for {} failed (non-fatal): {}", bucketName, e.getMessage()); + } + return null; + } + + @Override + public Map getAllBucketsUsage(final long storeId) { + throw new UnsupportedOperationException("ECS: getAllBucketsUsage is not implemented in this plugin"); + } + + // ---- user / secret management ---- + + private void ensureAccountUserAndSecret(final long accountId, final String username, final EcsCfg cfg) { + final String haveAcc = EcsUtils.valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_ACCESS)); + final String haveSec = EcsUtils.valueOrNull(accountDetailsDao.findDetail(accountId, AD_KEY_SECRET)); + + // If the stored access key is stale (different prefix/username), clear it so we re-provision. + final boolean stale = !EcsUtils.isBlank(haveAcc) && !username.equals(haveAcc); + if (stale) { + logger.info("ECS: stored access key '{}' does not match expected username '{}'; re-provisioning credentials.", + haveAcc, username); + accountDetailsDao.deleteDetails(accountId); + } + final String effectiveHaveAcc = stale ? null : haveAcc; + final String effectiveHaveSec = stale ? null : haveSec; + + try { + mgmtCallWithRetry401(cfg, token -> { + try (CloseableHttpClient http = EcsUtils.buildHttpClient(cfg.insecure)) { + createEcsUser(http, cfg, token, accountId, username); + reconcileOrCreateSecret(http, cfg, token, accountId, username, effectiveHaveAcc, effectiveHaveSec); + } + return null; + }); + } catch (CloudRuntimeException e) { + throw e; + } catch (Exception e) { + throw new CloudRuntimeException("Failed to ensure/reconcile ECS user/secret: " + e.getMessage(), e); + } + } + + private void createEcsUser(final CloseableHttpClient http, final EcsCfg cfg, final String token, + final long accountId, final String username) throws Exception { + final String xml = + "" + + "" + username + "" + + "" + cfg.ns + "" + + "" + + ""; + final HttpPost post = new HttpPost(cfg.mgmtUrl + "/object/users"); + post.setHeader("X-SDS-AUTH-TOKEN", token); + post.setHeader("Content-Type", "application/xml"); + post.setEntity(new StringEntity(xml, StandardCharsets.UTF_8)); + try (CloseableHttpResponse r = http.execute(post)) { + 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"); + if (st == 200 || st == 201) { + logger.info("ECS user ensured/created for accountId={} -> {}", accountId, username); + } else if (st == 400 && 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); + } + } + } + + private void reconcileOrCreateSecret(final CloseableHttpClient http, final EcsCfg cfg, final String token, + final long accountId, final String username, + final String haveAcc, final String haveSec) throws Exception { + if (!EcsUtils.isBlank(haveAcc) && !EcsUtils.isBlank(haveSec)) { + reconcileExistingAcsSecret(http, cfg, token, accountId, username, haveSec); + return; + } + if (adoptExistingEcsSecret(http, cfg, token, accountId, username, haveAcc, haveSec)) { + return; + } + createNewSecret(http, cfg, token, accountId, username, haveAcc, haveSec); + } + + private void reconcileExistingAcsSecret(final CloseableHttpClient http, final EcsCfg cfg, final String token, + final long accountId, final String username, final String haveSec) throws Exception { + logger.info("ECS single-key policy: accountId={} already has keys in ACS; skipping secret creation.", accountId); + try { + final List ecsKeys = fetchEcsUserSecrets(http, cfg.mgmtUrl, token, username); + if (ecsKeys.isEmpty()) { + pushSecretToEcs(http, cfg, token, username, haveSec, accountId); + } + } catch (EcsUnauthorizedException u) { + throw u; + } catch (Exception e) { + logger.debug("ECS secret reconcile check skipped for {}: {}", username, e.getMessage()); + } + } + + private void pushSecretToEcs(final CloseableHttpClient http, final EcsCfg cfg, final String token, + final String username, final String secret, final long accountId) throws Exception { + final String xml = + "" + + "" + cfg.ns + "" + + "" + secret + "" + + ""; + final HttpPost post = new HttpPost(cfg.mgmtUrl + "/object/user-secret-keys/" + username); + post.setHeader("X-SDS-AUTH-TOKEN", token); + post.setHeader("Content-Type", "application/xml"); + post.setEntity(new StringEntity(xml, StandardCharsets.UTF_8)); + try (CloseableHttpResponse r = http.execute(post)) { + 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 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.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); + } + } + } + + private boolean adoptExistingEcsSecret(final CloseableHttpClient http, final EcsCfg cfg, final String token, + final long accountId, final String username, + final String haveAcc, final String haveSec) throws Exception { + try { + final List ecsKeys = fetchEcsUserSecrets(http, cfg.mgmtUrl, token, username); + if (!ecsKeys.isEmpty()) { + final String adopt = ecsKeys.get(0); + if (EcsUtils.isBlank(haveAcc)) accountDetailsDao.addDetail(accountId, AD_KEY_ACCESS, username, false); + if (EcsUtils.isBlank(haveSec)) accountDetailsDao.addDetail(accountId, AD_KEY_SECRET, adopt, false); + logger.info("Adopted existing ECS secret for user {} into ACS.", username); + return true; + } + } catch (EcsUnauthorizedException u) { + throw u; + } catch (Exception e) { + logger.debug("Failed to fetch existing ECS keys for {} (proceeding to create one): {}", username, e.getMessage()); + } + return false; + } + + private void createNewSecret(final CloseableHttpClient http, final EcsCfg cfg, final String token, + final long accountId, final String username, + final String haveAcc, final String haveSec) throws Exception { + final String newSecret = UUID.randomUUID().toString().replace("-", ""); + final String xml = + "" + + "" + cfg.ns + "" + + "" + newSecret + "" + + ""; + final HttpPost post = new HttpPost(cfg.mgmtUrl + "/object/user-secret-keys/" + username); + post.setHeader("X-SDS-AUTH-TOKEN", token); + post.setHeader("Content-Type", "application/xml"); + post.setEntity(new StringEntity(xml, StandardCharsets.UTF_8)); + try (CloseableHttpResponse r = http.execute(post)) { + 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 create secret got 401"); + if (st != 200 && st != 201) { + if (st == 400 && rb.contains("already has") && rb.contains("valid keys")) { + adoptExistingEcsSecret(http, cfg, token, accountId, username, haveAcc, haveSec); + 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 (EcsUtils.isBlank(haveAcc)) accountDetailsDao.addDetail(accountId, AD_KEY_ACCESS, username, false); + if (EcsUtils.isBlank(haveSec)) accountDetailsDao.addDetail(accountId, AD_KEY_SECRET, newSecret, false); + logger.info("ECS secret key created and stored for user={} (accountId={})", username, accountId); + } + + private List fetchEcsUserSecrets(final CloseableHttpClient http, + final String mgmtUrl, final String token, + final String username) throws Exception { + final HttpGet get = new HttpGet(mgmtUrl + "/object/user-secret-keys/" + username); + get.setHeader("X-SDS-AUTH-TOKEN", token); + try (CloseableHttpResponse r = http.execute(get)) { + final int st = r.getStatusLine().getStatusCode(); + if (st == 401) throw new EcsUnauthorizedException("ECS fetch secrets got 401"); + if (st == 200) { + final String xml = r.getEntity() != null ? EntityUtils.toString(r.getEntity(), StandardCharsets.UTF_8) : ""; + final List out = new ArrayList<>(); + final String s1 = parseXmlTag(xml, "secret_key_1"); + final String s2 = parseXmlTag(xml, "secret_key_2"); + if ("true".equalsIgnoreCase(parseXmlTag(xml, "secret_key_1_exist")) && !EcsUtils.isBlank(s1)) out.add(s1.trim()); + if ("true".equalsIgnoreCase(parseXmlTag(xml, "secret_key_2_exist")) && !EcsUtils.isBlank(s2)) out.add(s2.trim()); + return out; + } + return new ArrayList<>(); + } + } + + // ---- S3 endpoint resolution ---- + + private static final class S3Endpoint { + final String scheme; + final String host; + S3Endpoint(final String scheme, final String host) { + this.scheme = scheme; + this.host = host; + } + } + + private S3Endpoint resolveS3Endpoint(final Map ds) { + String host = normalizeHostOnly(ds.get(S3_HOST)); + if (EcsUtils.isBlank(host)) host = normalizeHostOnly(ds.get("host")); + return new S3Endpoint("https", host); + } + + private String resolveS3HostForUI(final Map ds) { + String host = normalizeHostOnly(ds.get(S3_HOST)); + if (EcsUtils.isBlank(host)) host = normalizeHostOnly(ds.get("host")); + return host; + } + + // ---- HTTP helpers ---- + + private String buildPolicyUrl(final EcsCfg cfg, final String bucketName) { + try { + return cfg.mgmtUrl + "/object/bucket/" + bucketName + "/policy?namespace=" + + URLEncoder.encode(cfg.ns, StandardCharsets.UTF_8.name()); + } catch (Exception e) { + throw new CloudRuntimeException("ECS: failed to encode namespace", e); + } + } + + private String getBucketPolicyRaw(final String url, final String token, final boolean insecure) { + try (CloseableHttpClient http = EcsUtils.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 ""; + throw new CloudRuntimeException("ECS getBucketPolicy failed: HTTP " + st + " body=" + body); + } + } catch (EcsUnauthorizedException u) { + throw u; + } catch (CloudRuntimeException e) { + throw e; + } catch (Exception e) { + throw new CloudRuntimeException("ECS getBucketPolicy error: " + e.getMessage(), e); + } + } + + private void putBucketPolicy(final String url, final String token, final String policyJson, final boolean insecure) { + try (CloseableHttpClient http = EcsUtils.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); + throw new CloudRuntimeException("ECS setBucketPolicy failed: HTTP " + st + " body=" + body); + } + } catch (EcsUnauthorizedException u) { + throw u; + } catch (CloudRuntimeException e) { + throw e; + } catch (Exception e) { + throw new CloudRuntimeException("ECS setBucketPolicy error: " + e.getMessage(), e); + } + } + + private void deleteBucketPolicyHttp(final String url, final String token, final boolean insecure) { + try (CloseableHttpClient http = EcsUtils.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); + throw new CloudRuntimeException("ECS deleteBucketPolicy failed: HTTP " + st + " body=" + body); + } + } catch (EcsUnauthorizedException u) { + throw u; + } catch (CloudRuntimeException e) { + throw e; + } catch (Exception e) { + throw new CloudRuntimeException("ECS deleteBucketPolicy error: " + e.getMessage(), e); + } + } + + // ---- S3 SigV2 helpers ---- + + private static String rfc1123Now() { + return DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US) + .withZone(ZoneOffset.UTC) + .format(Instant.now()); + } + + private static String base64Md5(final byte[] data) throws Exception { + return Base64.getEncoder().encodeToString(MessageDigest.getInstance("MD5").digest(data)); + } + + private static String hmacSha1Base64(final String data, final String key) throws Exception { + final Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA1")); + return Base64.getEncoder().encodeToString(mac.doFinal(data.getBytes(StandardCharsets.UTF_8))); + } + + // ---- VO / reflection helpers ---- + + 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 (!EcsUtils.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 (Throwable ignore) { } + } + return findBucketVOByName(bucket.getName(), storeId); + } + + private BucketVO findBucketVOByName(final String name, final long storeId) { + try { + final List buckets = bucketDao.listByObjectStoreId(storeId); + if (buckets != null) { + for (BucketVO vo : buckets) { + if (name.equals(vo.getName())) return vo; + } + } + } catch (Throwable t) { + logger.debug("ECS: findBucketVOByName '{}' failed: {}", name, t.getMessage()); + } + return null; + } + + 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 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 (Exception ignored) { } + if (v == null) { + try { v = o.getClass().getMethod(isMethod).invoke(o); } catch (Exception ignored) { } + } + 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 ("true".equalsIgnoreCase(s) || "1".equals(s)) return true; + if ("false".equalsIgnoreCase(s) || "0".equals(s)) return false; + } + return defVal; + } + + private static String normalizeHostOnly(final String hostOrUrl) { + if (hostOrUrl == null) return null; + String h = hostOrUrl.trim(); + if (h.startsWith("https://")) h = h.substring(8); + else if (h.startsWith("http://")) h = h.substring(7); + while (h.endsWith("/")) h = h.substring(0, h.length() - 1); + return h; + } + + // ---- user prefix ---- + + private String getUserPrefix(final Map ds) { + final String p = ds != null ? ds.get(EcsConstants.USER_PREFIX) : null; + return StringUtils.isBlank(p) ? EcsConstants.DEFAULT_USER_PREFIX : p.trim(); + } + + // ---- XML helpers (delegated to EcsXmlParser) ---- + + private static String extractTag(final String xml, final String tag) { + return EcsXmlParser.extractTag(xml, tag); + } + + private static List extractAllTags(final String xml, final String tag) { + return EcsXmlParser.extractAllTags(xml, tag); + } + + private static String parseXmlTag(final String xml, final String tag) { + return EcsXmlParser.extractTag(xml, tag); + } + + private static Integer parseIntTag(final String xml, final String tag) { + return EcsXmlParser.parseIntTag(xml, tag); + } +} diff --git a/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsUnauthorizedException.java b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsUnauthorizedException.java new file mode 100644 index 000000000000..2fb3db931958 --- /dev/null +++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsUnauthorizedException.java @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.cloudstack.storage.datastore.driver; + +import com.cloud.utils.exception.CloudRuntimeException; + +public class EcsUnauthorizedException extends CloudRuntimeException { + public EcsUnauthorizedException(final String msg) { + super(msg); + } +} diff --git a/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsUtils.java b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsUtils.java new file mode 100644 index 000000000000..ee7b67da1417 --- /dev/null +++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsUtils.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.cloudstack.storage.datastore.driver; + +import javax.net.ssl.SSLContext; + +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +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.user.AccountDetailVO; +import com.cloud.utils.exception.CloudRuntimeException; + +public final class EcsUtils { + + private EcsUtils() { } + + public static boolean isBlank(final String s) { + return s == null || s.trim().isEmpty(); + } + + /** Strips a trailing slash from a URL. */ + public static String trimTail(final String s) { + if (s == null) return null; + final String t = s.trim(); + return t.endsWith("/") ? t.substring(0, t.length() - 1) : t; + } + + public static String valueOrNull(final AccountDetailVO d) { + return d == null ? null : d.getValue(); + } + + public static CloseableHttpClient buildHttpClient(final boolean insecure) { + if (!insecure) return HttpClients.createDefault(); + try { + final TrustStrategy trustAll = (chain, authType) -> true; + final SSLContext sslContext = SSLContextBuilder.create() + .loadTrustMaterial(null, trustAll) + .build(); + return HttpClients.custom() + .setSSLContext(sslContext) + .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE) + .build(); + } catch (Exception e) { + throw new CloudRuntimeException("ECS: failed to build HttpClient", e); + } + } + + /** + * Performs a one-shot ECS /login to verify connectivity and credentials. + * Used by the lifecycle during store initialization. + */ + public static void verifyLogin(final String mgmtUrl, final String user, final String pass, final boolean insecure) { + try (CloseableHttpClient http = buildHttpClient(insecure)) { + final HttpGet get = new HttpGet(mgmtUrl + "/login"); + get.addHeader(new BasicScheme().authenticate( + new UsernamePasswordCredentials(user, pass), get, null)); + try (CloseableHttpResponse resp = http.execute(get)) { + final int status = resp.getStatusLine().getStatusCode(); + if (status != 200 && status != 201) { + throw new CloudRuntimeException("ECS /login failed: HTTP " + status); + } + if (resp.getFirstHeader("X-SDS-AUTH-TOKEN") == null) { + throw new CloudRuntimeException("ECS /login missing X-SDS-AUTH-TOKEN header"); + } + } + } catch (CloudRuntimeException e) { + throw e; + } catch (Exception e) { + throw new CloudRuntimeException("ECS: management login error: " + e.getMessage(), e); + } + } +} diff --git a/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsXmlParser.java b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsXmlParser.java new file mode 100644 index 000000000000..2d43b639dc37 --- /dev/null +++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/driver/EcsXmlParser.java @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.cloudstack.storage.datastore.driver; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** Lightweight XML-extraction utilities for ECS API responses. */ +public final class EcsXmlParser { + + private EcsXmlParser() { } + + /** Returns the text content of the first occurrence of <tag>…</tag>, or {@code null}. */ + public static String extractTag(final String xml, final String tag) { + if (xml == null) return null; + final String open = "<" + tag + ">", close = ""; + final int i = xml.indexOf(open); + if (i < 0) return null; + final int j = xml.indexOf(close, i + open.length()); + if (j < 0) return null; + return xml.substring(i + open.length(), j).trim(); + } + + /** Returns the text content of every occurrence of <tag>…</tag>. */ + public static List extractAllTags(final String xml, final String tag) { + final List out = new ArrayList<>(); + if (xml == null) return out; + final String open = "<" + tag + ">", close = ""; + int from = 0; + while (true) { + final int i = xml.indexOf(open, from); + if (i < 0) break; + final 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; + } + + /** Parses the integer value of a tag, or returns {@code null} on missing/invalid content. */ + public static Integer parseIntTag(final String xml, final String tag) { + final String val = extractTag(xml, tag); + if (val == null) return null; + try { return Integer.parseInt(val); } catch (NumberFormatException ignore) { return null; } + } + + /** Collects every {@code } value inside {@code } blocks (S3 list-objects response). */ + public static void extractKeysFromListBucketXml(final String xml, final List keys) { + if (xml == null) return; + final String open = "", close = ""; + int from = 0; + while (true) { + final int i = xml.indexOf(open, from); + if (i < 0) break; + final int j = xml.indexOf(close, i + open.length()); + if (j < 0) break; + final String key = extractTag(xml.substring(i, j + close.length()), "Key"); + if (key != null && !key.isEmpty()) keys.add(key.trim()); + from = j + close.length(); + } + } + + /** Returns {@code true} if the HTTP 400 body looks like a "bucket already exists" error. */ + public static boolean looksLikeBucketAlreadyExists400(final String body) { + if (body == null) return false; + final String lb = body.toLowerCase(Locale.ROOT); + return lb.contains("already exist") || lb.contains("already_exists") || + lb.contains("already-exists") || lb.contains("name already in use") || + lb.contains("bucket exists") || lb.contains("duplicate"); + } +} diff --git a/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/EcsObjectStoreLifeCycleImpl.java b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/EcsObjectStoreLifeCycleImpl.java new file mode 100644 index 000000000000..d8590910e4f6 --- /dev/null +++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/EcsObjectStoreLifeCycleImpl.java @@ -0,0 +1,199 @@ +/* + * 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 org.apache.cloudstack.engine.subsystem.api.storage.ClusterScope; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; +import org.apache.cloudstack.engine.subsystem.api.storage.HostScope; +import org.apache.cloudstack.engine.subsystem.api.storage.ZoneScope; +import org.apache.cloudstack.storage.datastore.db.ObjectStoreVO; +import org.apache.cloudstack.storage.datastore.driver.EcsCfg; +import org.apache.cloudstack.storage.datastore.driver.EcsUtils; +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.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.cloud.agent.api.StoragePoolInfo; +import com.cloud.hypervisor.Hypervisor.HypervisorType; +import com.cloud.utils.exception.CloudRuntimeException; + +public class EcsObjectStoreLifeCycleImpl implements ObjectStoreLifeCycle { + + private static final Logger LOG = LogManager.getLogger(EcsObjectStoreLifeCycleImpl.class); + + private static final String 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"; + private static final String S3_HOST = "s3_host"; + private static final String NAMESPACE = "namespace"; + + static final String PROVIDER_NAME = "ECS"; + + @Inject ObjectStoreHelper objectStoreHelper; + @Inject ObjectStoreProviderManager objectStoreMgr; + + public EcsObjectStoreLifeCycleImpl() { } + + @Override + public DataStore initialize(final Map dsInfos) { + requireInjected(); + + final String url = getString(dsInfos, "url", true); + final String name = getString(dsInfos, "name", true); + final Long size = getLong(dsInfos, "size"); + + final Map details = getDetails(dsInfos); + final EcsCfg cfg = verifyAndNormalize(details); + + LOG.info("ECS initialize: provider='{}', name='{}', url='{}', mgmt_url='{}', insecure={}, s3_host='{}', namespace='{}'", + PROVIDER_NAME, name, url, cfg.mgmtUrl, cfg.insecure, + details.get(S3_HOST), details.get(NAMESPACE)); + + EcsUtils.verifyLogin(cfg.mgmtUrl, cfg.saUser, cfg.saPass, cfg.insecure); + + applyCanonicalDetails(details, cfg); + + final Map objectStoreParameters = buildObjectStoreParams(name, url, size); + + try { + LOG.info("ECS: creating ObjectStore in DB: name='{}', provider='{}', url='{}'", + name, PROVIDER_NAME, url); + + final ObjectStoreVO objectStore = objectStoreHelper.createObjectStore(objectStoreParameters, details); + if (objectStore == null) { + throw new CloudRuntimeException("ECS: createObjectStore returned null"); + } + + final DataStore store = objectStoreMgr.getObjectStore(objectStore.getId()); + if (store == null) { + throw new CloudRuntimeException("ECS: getObjectStore returned null for id=" + objectStore.getId()); + } + + LOG.info("ECS: object store created: id={}, name='{}'", objectStore.getId(), name); + return store; + } catch (RuntimeException e) { + final String msg = "ECS: failed to persist object store '" + name + "': " + safeMsg(e); + LOG.error(msg, e); + throw new CloudRuntimeException(msg, e); + } + } + + @Override public boolean attachCluster(final DataStore store, final ClusterScope scope) { return false; } + @Override public boolean attachHost(final DataStore store, final HostScope scope, final StoragePoolInfo existingInfo) { return false; } + @Override public boolean attachZone(final DataStore dataStore, final ZoneScope scope, final HypervisorType hypervisorType) { return false; } + @Override public boolean maintain(final DataStore store) { return false; } + @Override public boolean cancelMaintain(final DataStore store) { return false; } + @Override public boolean deleteDataStore(final DataStore store) { return false; } + @Override public boolean migrateToObjectStore(final DataStore store) { return false; } + + // ---------- helpers ---------- + + private void requireInjected() { + if (objectStoreHelper == null) { + throw new CloudRuntimeException("ECS: ObjectStoreHelper is not injected"); + } + if (objectStoreMgr == null) { + throw new CloudRuntimeException("ECS: ObjectStoreProviderManager is not injected"); + } + } + + private static String getString(final Map dsInfos, final String key, final boolean required) { + final Object v = dsInfos.get(key); + final String s = v == null ? null : v.toString().trim(); + if (required && StringUtils.isEmpty(s)) { + throw new CloudRuntimeException("ECS: missing required parameter '" + key + "'"); + } + return s; + } + + private static Long getLong(final Map dsInfos, final String key) { + final Object v = dsInfos.get(key); + if (v == null) return null; + return (Long) v; + } + + private static Map getDetails(final Map dsInfos) { + final Object v = dsInfos.get("details"); + if (!(v instanceof Map)) { + throw new CloudRuntimeException("ECS: details map is missing"); + } + final Map raw = (Map) v; + final Map out = new HashMap<>(); + for (Map.Entry e : raw.entrySet()) { + if (e.getKey() == null) continue; + out.put(e.getKey().toString(), e.getValue() == null ? null : e.getValue().toString()); + } + return out; + } + + private static EcsCfg verifyAndNormalize(final Map details) { + final String mgmtUrl = EcsUtils.trimTail(details.get(MGMT_URL)); + final String saUser = safe(details.get(SA_USER)); + final String saPass = safe(details.get(SA_PASS)); + final String ns = StringUtils.defaultIfBlank(details.get(NAMESPACE), "default"); + final boolean insecure = Boolean.parseBoolean(details.getOrDefault(INSECURE, "false")); + + verifyRequiredDetail(MGMT_URL, mgmtUrl); + verifyRequiredDetail(SA_USER, saUser); + verifyRequiredDetail(SA_PASS, saPass); + + return new EcsCfg(mgmtUrl, saUser, saPass, ns, insecure); + } + + private static void verifyRequiredDetail(final String key, final String value) { + if (StringUtils.isEmpty(value)) { + throw new CloudRuntimeException("ECS: missing required detail '" + key + "'"); + } + } + + private static void applyCanonicalDetails(final Map details, final EcsCfg 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)); + } + + private static Map buildObjectStoreParams(final String name, final String url, final Long size) { + final Map p = new HashMap<>(); + p.put("name", name); + p.put("url", url); + p.put("size", size); + p.put("providerName", PROVIDER_NAME); + return p; + } + + private static String safe(final String v) { + return v == null ? "" : v.trim(); + } + + private static String safeMsg(final Throwable t) { + if (t == null) return "unknown"; + final String m = t.getMessage(); + return StringUtils.isEmpty(m) ? t.getClass().getSimpleName() : m; + } +} diff --git a/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/provider/EcsObjectStoreProviderImpl.java b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/provider/EcsObjectStoreProviderImpl.java new file mode 100644 index 000000000000..4661ed0ebe17 --- /dev/null +++ b/plugins/storage/object/ECS/src/main/java/org/apache/cloudstack/storage/datastore/provider/EcsObjectStoreProviderImpl.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.cloudstack.storage.datastore.provider; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import javax.inject.Inject; + +import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreDriver; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreLifeCycle; +import org.apache.cloudstack.engine.subsystem.api.storage.HypervisorHostListener; +import org.apache.cloudstack.engine.subsystem.api.storage.ObjectStoreProvider; +import org.apache.cloudstack.storage.datastore.driver.EcsObjectStoreDriverImpl; +import org.apache.cloudstack.storage.datastore.lifecycle.EcsObjectStoreLifeCycleImpl; +import org.apache.cloudstack.storage.object.ObjectStoreDriver; +import org.apache.cloudstack.storage.object.datastore.ObjectStoreHelper; +import org.apache.cloudstack.storage.object.datastore.ObjectStoreProviderManager; +import org.apache.cloudstack.storage.object.store.lifecycle.ObjectStoreLifeCycle; +import org.springframework.stereotype.Component; + +import com.cloud.utils.component.ComponentContext; + +@Component +public class EcsObjectStoreProviderImpl implements ObjectStoreProvider { + + @Inject + ObjectStoreProviderManager storeMgr; + + @Inject + ObjectStoreHelper helper; + + private final String providerName = "ECS"; + + protected ObjectStoreLifeCycle lifeCycle; + protected ObjectStoreDriver driver; + + @Override + public String getName() { + return providerName; + } + + @Override + public boolean configure(Map params) { + // Follow Ceph provider pattern + lifeCycle = ComponentContext.inject(EcsObjectStoreLifeCycleImpl.class); + driver = ComponentContext.inject(EcsObjectStoreDriverImpl.class); + storeMgr.registerDriver(getName(), driver); + return true; + } + + @Override + public DataStoreLifeCycle getDataStoreLifeCycle() { + return lifeCycle; + } + + @Override + public DataStoreDriver getDataStoreDriver() { + return driver; + } + + @Override + public HypervisorHostListener getHostListener() { + return null; + } + + @Override + public Set getTypes() { + Set types = new HashSet<>(); + types.add(DataStoreProviderType.OBJECT); + return types; + } +} diff --git a/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/module.properties b/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/module.properties new file mode 100644 index 000000000000..46e642a9ec1b --- /dev/null +++ b/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/module.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +name=storage-object-ecs +parent=storage diff --git a/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/spring-storage-object-ecs-context.xml b/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/spring-storage-object-ecs-context.xml new file mode 100644 index 000000000000..e7345e43455b --- /dev/null +++ b/plugins/storage/object/ECS/src/main/resources/META-INF/cloudstack/storage-object-ecs/spring-storage-object-ecs-context.xml @@ -0,0 +1,31 @@ + + + + diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 4a23b454252a..82d24ad92edf 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -19,7 +19,6 @@ "error.release.dedicate.pod": "Failed to release dedicated Pod.", "error.release.dedicate.zone": "Failed to release dedicated Zone.", "error.unable.to.add.setting.extraconfig": "It is not allowed to add setting for extraconfig. Please update VirtualMachine with extraconfig parameter.", -"error.unable.to.add.setting": "Unable to add or edit setting", "error.unable.to.proceed": "Unable to proceed. Please contact your administrator.", "firewall.close": "Firewall", "icmp.code.desc": "Please specify -1 if you want to allow all ICMP codes (except NSX zones).", @@ -43,14 +42,11 @@ "label.accounts": "Accounts", "label.accountstate": "Account state", "label.accounttype": "Account type", -"label.import": "Import", -"label.acl.import": "Import rules", -"label.acl.export": "Export rules", +"label.acl.export": "Export ACL rules", "label.acl.id": "ACL ID", "label.acl.rules": "ACL rules", "label.acl.reason.description": "Enter the reason behind an ACL rule.", "label.aclid": "ACL", -"label.aclname": "ACL name", "label.acl.rule.name": "ACL rule name", "label.acquire.new.ip": "Acquire new IP", "label.acquire.new.secondary.ip": "Acquire new secondary IP", @@ -75,7 +71,7 @@ "label.action.cancel.maintenance.mode": "Cancel maintenance mode", "label.action.change.password": "Change password", "label.action.clear.webhook.deliveries": "Clear deliveries", -"label.action.clear.webhook.filters": "Clear filters", +"label.action.delete.webhook.deliveries": "Delete deliveries", "label.action.change.primary.storage.scope": "Change Primary Storage scope", "label.action.configure.stickiness": "Stickiness", "label.action.configure.storage.access.group": "Update storage access group", @@ -119,8 +115,6 @@ "label.action.delete.user": "Delete User", "label.action.delete.vgpu.profile": "Delete vGPU profile", "label.action.delete.volume": "Delete Volume", -"label.action.delete.webhook.deliveries": "Delete Deliveries", -"label.action.delete.webhook.filters": "Delete Filters", "label.action.delete.zone": "Delete Zone", "label.action.destroy.instance": "Destroy Instance", "label.action.destroy.systemvm": "Destroy System VM", @@ -256,9 +250,8 @@ "label.activate.project": "Activate project", "label.activeviewersessions": "Active sessions", "label.add": "Add", -"label.addservices": "Add Services", "label.add.account": "Add Account", -"label.add.acl.rule": "Add rule", +"label.add.acl.rule": "Add ACL rule", "label.add.acl": "Add ACL", "label.add.affinity.group": "Add new Affinity Group", "label.add.backup.schedule": "Add Backup Schedule", @@ -296,10 +289,8 @@ "label.add.isolated.network": "Add Isolated Network", "label.add.kubernetes.cluster": "Add Kubernetes Cluster", "label.add.acl.name": "ACL name", -"label.add.latest.kubernetes.iso": "Add latest Kubernetes ISO", "label.add.ldap.account": "Add LDAP Account", "label.add.logical.router": "Add Logical Router to this Network", -"label.add.minimum.required.compute.offering": "Add minimum required Compute Offering", "label.add.more": "Add more", "label.add.nodes": "Add Nodes to Kubernetes Cluster", "label.add.netscaler.device": "Add Netscaler Device", @@ -364,7 +355,6 @@ "label.add.vpn.customer.gateway": "Add VPN Customer Gateway", "label.add.vpn.gateway": "Add VPN Gateway", "label.add.vpn.user": "Add VPN User", -"label.add.webhook.filter": "Add Webhook Filter", "label.add.zone": "Add Zone", "label.adding": "Adding", "label.adding.user": "Adding User...", @@ -413,7 +403,6 @@ "label.app.name": "CloudStack", "label.application.policy.set": "Application Policy Set", "label.apply": "Apply", -"label.apply.to.all": "Apply to all", "label.apply.tungsten.firewall.policy": "Apply Firewall Policy", "label.apply.tungsten.network.policy": "Apply Network Policy", "label.apply.tungsten.tag": "Apply tag", @@ -446,8 +435,6 @@ "label.auto.assign": "Automatically assign", "label.auto.assign.diskoffering.disk.size": "Automatically assign offering matching the disk size", "label.auto.assign.random.ip": "Automatically assign a random IP address", -"label.auto.refresh.statistics": "Period between auto refreshes", -"label.auto.refresh.statistics.none": "None", "label.automigrate.volume": "Auto migrate volume to another storage pool if required", "label.autoscale.vm.groups": "AutoScaling Groups", "label.autoscale.vm.profile": "AutoScale Instance Profile", @@ -540,14 +527,11 @@ "label.change.ipaddress": "Change IP address for NIC", "label.change.disk.offering": "Change disk offering", "label.change.offering.for.volume": "Change disk offering for the volume", -"label.change.password.onlogin": "User must change password at next login", -"label.change.password.reset": "Force password reset", "label.change.service.offering": "Change service offering", "label.character": "Character", "label.checksum": "Checksum", "label.choose.resource.icon": "Choose icon", "label.choose.saml.identity": "Choose SAML identity provider", -"label.choose.isolation.method.public.ip.range": "Choose the proper isolation method for the public IP range in accordance with the zone. Valid options currently 'vlan' or 'vxlan', defaults to 'vlan'.", "label.cidr": "CIDR", "label.cidrsize": "CIDR size", "label.cidr.destination.network": "Destination Network CIDR", @@ -567,21 +551,12 @@ "label.cks.cluster.size": "Cluster size (Worker nodes)", "label.cks.cluster.worker.nodes.offeringid": "Service Offering for Worker Nodes", "label.cks.cluster.worker.nodes.templateid": "Template for Worker Nodes", -"label.cks.cluster.control.nodes.affinitygroupid": "Affinity Groups for Control Nodes", -"label.cks.cluster.worker.nodes.affinitygroupid": "Affinity Groups for Worker Nodes", -"label.cks.cluster.etcd.nodes.affinitygroupid": "Affinity Groups for ETCD Nodes", "label.cleanup": "Clean up", "label.clear": "Clear", "label.clear.all": "Clear all", "label.clear.list": "Clear list", "label.clear.notification": "Clear notification", "label.clientid": "Provider Client ID", -"label.clone.backup.offering": "Clone Backup Offering", -"label.clone.compute.offering": "Clone Compute Offering", -"label.clone.disk.offering": "Clone Disk Offering", -"label.clone.network.offering": "Clone Network Offering", -"label.clone.system.service.offering": "Clone System Service Offering", -"label.clone.vpc.offering": "Clone VPC Offering", "label.close": "Close", "label.cloud.managed": "CloudManaged", "label.cloudian.admin.password": "Admin Service Password", @@ -657,7 +632,6 @@ "label.consoleproxy": "Console proxy", "label.console.proxy": "Console proxy", "label.console.proxy.vm": "Console proxy VM", -"label.contains": "Contains", "label.continue": "Continue", "label.continue.install": "Continue with installation", "label.controlnodes": "Control nodes", @@ -668,8 +642,6 @@ "label.copy.consoleurl": "Copy console URL to clipboard", "label.copyid": "Copy ID", "label.copy.password": "Copy password", -"label.copy.templates.from.other.secondary.storages": "Copy Templates from other storages instead of fetching from URLs", -"label.copy.templates.from.other.secondary.storages.add.zone": "Copy Templates from other storages", "label.core": "Core", "label.core.zone.type": "Core Zone type", "label.count": "Count", @@ -699,7 +671,6 @@ "label.create.sharedfs": "Create Shared FileSystem", "label.create.network": "Create new Network", "label.create.nfs.secondary.staging.storage": "Create NFS secondary staging storage", -"label.create.on.storage": "Create on Storage", "label.create.project": "Create Project", "label.create.project.role": "Create Project Role", "label.create.routing.policy": "Create Routing Policy", @@ -716,7 +687,6 @@ "label.create.tier.networkofferingid.description": "The Network offering for the Network Tier.", "label.create.tungsten.routing.policy": "Create Tungsten-Fabric routing policy", "label.create.user": "Create User", -"label.create.volume.on.primary.storage": "Create Volume on the specified Primary Storage", "label.create.vm": "Create Instance", "label.create.vm.and.stay": "Create Instance & stay on this page", "label.create.vpn.connection": "Create VPN connection", @@ -729,7 +699,6 @@ "label.cron.mode": "Cron mode", "label.crosszones": "Cross Zones", "label.csienabled": "CSI Enabled", -"label.csv.preview": "Data preview", "label.currency": "Currency", "label.current": "Current", "label.currentstep": "Current step", @@ -832,12 +801,11 @@ "label.delete.tungsten.service.group": "Delete Service Group", "label.delete.volumes": "Data volumes to be deleted", "label.delete.vpn.connection": "Delete VPN connection", -"label.delete.vpn.customer.gateway": "Delete VPN Customer Gateway", +"label.delete.vpn.customer.gateway": "Delete VPN customer gateway", "label.delete.vpn.gateway": "Delete VPN gateway", "label.delete.vpn.user": "Delete VPN User", "label.delete.webhook": "Delete Webhook", "label.delete.webhook.delivery": "Delete Webhook Delivery", -"label.delete.webhook.filter": "Delete Webhook Filter", "label.deleteconfirm": "Please confirm that you would like to delete this", "label.deleting": "Deleting", "label.deleting.failed": "Deleting failed", @@ -959,7 +927,6 @@ "label.domains": "Domains", "label.done": "Done", "label.down": "Down", -"label.dropservices": "Drop Services", "label.download": "Download", "label.download.csv": "Download CSV", "label.download.kubeconfig.cluster": "Download kubeconfig for the cluster

The kubectl command-line tool uses kubeconfig files to find the information it needs to choose a cluster and communicate with the API server of a cluster.", @@ -995,7 +962,6 @@ "label.edit.acl": "Edit ACL", "label.edit.acl.rule": "Edit ACL rule", "label.edit.autoscale.vmprofile": "Edit AutoScale Instance Profile", -"label.edit.nic": "Edit NIC", "label.edit.project.details": "Edit project details", "label.edit.project.role": "Edit project role", "label.edit.role": "Edit Role", @@ -1040,7 +1006,6 @@ "label.endpoint": "Endpoint", "label.endport": "End port", "label.enter.account.name": "Enter the account name", -"label.enter.domain.name": "Enter the domain name", "label.enter.code": "Enter 2FA code to verify", "label.enter.static.pin": "Enter static PIN to verify", "label.enter.token": "Enter token", @@ -1070,10 +1035,8 @@ "label.event.timeline": "Event timeline", "label.events": "Events", "label.every": "Every", -"label.exact": "Exact", "label.example": "Example", "label.example.plugin": "ExamplePlugin", -"label.exclude": "Exclude", "label.existing": "Existing", "label.execute": "Execute", "label.expunge": "Expunge", @@ -1112,7 +1075,6 @@ "label.shared.filesystems": "Shared FileSystems", "label.filesystem": "Filesystem", "label.filter": "Filter", -"label.filters": "Filters", "label.filter.annotations.all": "All comments", "label.filter.annotations.self": "Created by me", "label.filterby": "Filter by", @@ -1127,7 +1089,6 @@ "label.firstname": "First name", "label.firstname.lower": "firstname", "label.fix.errors": "Fix errors", -"label.fix.global.setting": "Fix Global Setting", "label.fixed": "Fixed Offering", "label.for": "for", "label.forcks": "For CKS", @@ -1161,9 +1122,6 @@ "label.globo.dns.configuration": "GloboDNS configuration", "label.glustervolume": "Volume", "label.go.back": "Go back", -"label.go.to.compute.offerings": "Go to Compute Offerings", -"label.go.to.global.settings": "Go to Global Settings", -"label.go.to.kubernetes.isos": "Go to Kubernetes ISOs", "label.gpu": "GPU", "label.gpucardid": "GPU Card", "label.gpucardname": "GPU Card", @@ -1245,8 +1203,6 @@ "label.host.alerts": "Hosts in alert state", "label.host.name": "Host name", "label.host.ovftool.version": "OVFTool Version", -"label.host.vddk.support": "VDDK Support", -"label.host.vddk.version": "VDDK Version", "label.host.tag": "Host tag", "label.host.virtv2v.version": "Virt-v2v Version", "label.hostcontrolstate": "Compute Resource Status", @@ -1297,7 +1253,6 @@ "label.import.volume": "Import Volume", "label.inactive": "Inactive", "label.inbuilt": "Inbuilt", -"label.include": "Include", "label.in.progress": "in progress", "label.in.progress.for": "in progress for", "label.info": "Info", @@ -1416,7 +1371,6 @@ "label.isoname": "Attached ISO", "label.isos": "ISOs", "label.isostate": "ISO state", -"label.isourl": "ISO URL", "label.ispersistent": "Persistent ", "label.ispublic": "Public", "label.isready": "Ready", @@ -1497,7 +1451,6 @@ "label.lbruleid": "Load balancer ID", "label.lbtype": "Load balancer type", "label.ldap": "LDAP", -"label.ldapdomain": "LDAP Domain", "label.ldap.configuration": "LDAP Configuration", "label.ldap.group.name": "LDAP Group", "label.level": "Level", @@ -1565,7 +1518,6 @@ "label.management.server.peers": "Peers", "label.managementservers": "Number of management servers", "label.matchall": "Match all", -"label.matchtype": "Match Type", "label.max": "Max.", "label.max.primary.storage": "Max. primary (GiB)", "label.max.secondary.storage": "Max. secondary (GiB)", @@ -1762,7 +1714,6 @@ "label.no.data": "No data to show", "label.no.errors": "No recent errors", "label.no.items": "No available Items", -"label.no.matching.guest.os.vmware.import": "No matching guest OS mapping found, using the default import template guest OS", "label.no.matching.offering": "No matching offering found", "label.no.matching.network": "No matching Networks found", "label.node.version": "Node version", @@ -2097,7 +2048,6 @@ "label.release.dedicated.pod": "Release dedicated Pod", "label.release.dedicated.zone": "Release dedicated Zone", "label.releasing.ip": "Releasing IP", -"label.remote.access.vpn.specify.iprange": "Specify IP Range of remote VPN", "label.remote.instances": "Remote Instances", "label.remove": "Remove", "label.remove.annotation": "Remove comment", @@ -2137,7 +2087,6 @@ "label.requireshvm": "HVM", "label.requiresupgrade": "Requires upgrade", "label.reserved": "Reserved", -"label.reservedresourcedetails": "Reserved resource details", "label.reserved.system.gateway": "Reserved system gateway", "label.reserved.system.ip": "Reserved system IP", "label.reserved.system.netmask": "Reserved system netmask", @@ -2409,7 +2358,6 @@ "label.user.data.policy.tooltip": "User Data linked to the Template can be overridden by User Data provided during Instance deploy. Select the override policy as required.", "label.user.data": "User Data", "label.user.data.library": "User Data Library", -"label.use.vddk": "Use VDDK", "label.ssh.port": "SSH port", "label.sshkeypair": "New SSH key pair", "label.sshkeypairs": "SSH key pairs", @@ -2475,7 +2423,6 @@ "label.storagepool.tooltip": "Destination Storage Pool. Volume should be located in this Storage Pool", "label.storagetags": "Storage tags", "label.storagetype": "Storage type", -"label.storageip": "Storage IP address", "label.strict": "Strict", "label.subdomainaccess": "Subdomain access", "label.submit": "Submit", @@ -2486,7 +2433,6 @@ "label.success.migrations": "Successful migrations", "label.success.set": "Successfully set", "label.success.updated": "Successfully updated", -"label.suffix": "Suffix", "label.suitability": "Suitability", "label.suitable": "Suitable", "label.summary": "Summary", @@ -2639,7 +2585,6 @@ "label.undefined": "Undefined", "label.unit": "Usage unit", "label.unknown": "Unknown", -"label.unlink.domain.from.ldap": "Unlink the Domain from LDAP", "label.unlimited": "Unlimited", "label.unmanaged": "Unmanaged", "label.unmanage.instance": "Unmanage Instance", @@ -2668,7 +2613,6 @@ "label.update.to": "updated to", "label.update.traffic.label": "Update traffic labels", "label.update.vmware.datacenter": "Update VMWare datacenter", -"label.update.vpn.customer.gateway": "Update VPN Customer Gateway", "label.update.webhook": "Update Webhook", "label.updating": "Updating", "label.upgrade.router.newer.template": "Upgrade router to use newer Template", @@ -2804,8 +2748,8 @@ "label.vnf.app.action.reinstall": "Reinstall VNF Appliance", "label.vnf.cidr.list": "CIDR from which access to the VNF appliance's Management interface should be allowed from", "label.vnf.cidr.list.tooltip": "the CIDR list to forward traffic from to the VNF management interface. Multiple entries must be separated by a single comma character (,). The default value is 0.0.0.0/0.", -"label.vnf.configure.management": "Configure network rules for VNF's management interfaces", -"label.vnf.configure.management.tooltip": "False by default, security group or network rules (source nat and firewall rules) will be configured for VNF management interfaces. True otherwise. Learn what rules are configured at http://docs.cloudstack.apache.org/en/latest/adminguide/networking/vnf_templates_appliances.html#deploying-vnf-appliances", +"label.vnf.configure.management": "Configure Firewall and Port Forwarding rules for VNF's management interfaces", +"label.vnf.configure.management.tooltip": "True by default, security group or network rules (source nat and firewall rules) will be configured for VNF management interfaces. False otherwise. Learn what rules are configured at http://docs.cloudstack.apache.org/en/latest/adminguide/networking/vnf_templates_appliances.html#deploying-vnf-appliances", "label.vnf.detail.add": "Add VNF detail", "label.vnf.detail.remove": "Remove VNF detail", "label.vnf.details": "VNF Details", @@ -2919,14 +2863,26 @@ "label.oobm.username": "Out-of-band management username", "label.bucket.update": "Update Bucket", "label.bucket.delete": "Delete Bucket", +"label.ecs.public.url": "ECS Public URL", +"label.ecs.public.url.tooltip": "The S3-compatible endpoint URL that clients use to connect to ECS", +"label.ecs.api.url": "ECS API URL", +"label.ecs.api.url.tooltip": "ECS management API URL", +"label.ecs.private.url": "ECS Private URL", +"label.ecs.private.url.tooltip": "The internal S3 endpoint URL used by CloudStack to communicate with ECS. May be the same as the Public URL.", +"label.ecs.sa.user": "ECS service account user", +"label.ecs.sa.user.tooltip": "Service account user, e.g. cloudstack", +"label.ecs.sa.password": "ECS service account password", +"label.ecs.sa.password.tooltip": "Service account password (sa_password)", +"label.ecs.namespace": "Namespace", +"label.ecs.namespace.tooltip": "ECS namespace (namespace), e.g. cloudstack", +"label.ecs.insecure": "Allow insecure HTTPS (set insecure=true)", +"label.ecs.user.prefix": "User prefix", +"label.ecs.user.prefix.tooltip": "Prefix used for ECS user creation. Default is cs- (user_prefix). Example: cs-", "label.quotagib": "Quota in GiB", "label.quotagb": "Quota in GB", "label.edgecluster": "Edge Cluster", "label.encryption": "Encryption", "label.etcdnodes": "Number of etcd nodes", -"label.controlaffinitygroupnames": "Control Affinity Groups", -"label.workeraffinitygroupnames": "Worker Affinity Groups", -"label.etcdaffinitygroupnames": "ETCD Affinity Groups", "label.versioning": "Versioning", "label.objectlocking": "Object Lock", "label.bucket.policy": "Bucket Policy", @@ -2954,8 +2910,6 @@ "message.action.create.snapshot.from.vmsnapshot": "Please confirm that you want to create Snapshot from Instance Snapshot", "message.action.create.instance.from.backup": "Please confirm that you want to create a new Instance from the given Backup.
Click on configure to edit the parameters for the new Instance before creation.", "message.create.instance.from.backup.different.zone": "Creating Instance from Backup on a different Zone. Please ensure that the backup repository is accessible in the selected Zone.", -"message.csv.empty": "Empty CSV File", -"message.csv.missing.headers": "Columns are missing from headers in CSV", "message.template.ostype.different.from.backup": "Selected Template has a different OS type than the Backup. Please proceed with caution.", "message.iso.ostype.different.from.backup": "Selected ISO has a different OS type than the Backup. Please proceed with caution.", "message.action.delete.asnrange": "Please confirm the AS range that you want to delete", @@ -3097,17 +3051,12 @@ "message.add.ip.v6.firewall.rule.failed": "Failed to add IPv6 firewall rule", "message.add.ip.v6.firewall.rule.processing": "Adding IPv6 firewall rule...", "message.add.ip.v6.firewall.rule.success": "Added IPv6 firewall rule", -"message.advisory.cks.endpoint.url.not.configured": "Endpoint URL which will be used by Kubernetes clusters is not configured correctly", -"message.advisory.cks.min.offering": "No suitable Compute Offering found for Kubernetes cluster nodes with minimum required resources (2 vCPU, 2 GB RAM)", -"message.advisory.cks.version.check": "No Kubernetes version found that can be used to deploy a Kubernetes cluster", "message.redeliver.webhook.delivery": "Redeliver this Webhook delivery", "message.remove.ip.v6.firewall.rule.failed": "Failed to remove IPv6 firewall rule", "message.remove.ip.v6.firewall.rule.processing": "Removing IPv6 firewall rule...", "message.remove.ip.v6.firewall.rule.success": "Removed IPv6 firewall rule", "message.remove.sslcert.failed": "Failed to remove SSL certificate from load balancer", "message.remove.sslcert.processing": "Removing SSL certificate from load balancer...", -"message.add.latest.kubernetes.iso.failed": "Failed to add latest Kubernetes ISO", -"message.add.minimum.required.compute.offering.kubernetes.cluster.failed": "Failed to add minimum required Compute Offering for Kubernetes cluster nodes", "message.add.netris.controller": "Add Netris Provider", "message.add.nsx.controller": "Add NSX Provider", "message.add.network": "Add a new network for Zone: ", @@ -3143,18 +3092,14 @@ "message.add.volume": "Please fill in the following data to add a new volume.", "message.add.vpn.connection.failed": "Adding VPN connection failed", "message.add.vpn.connection.processing": "Adding VPN Connection...", -"message.add.vpn.customer.gateway": "Adding VPN Customer Gateway", -"message.add.vpn.customer.gateway.processing": "Creation of VPN Customer Gateway is in progress", +"message.add.vpn.customer.gateway": "Adding VPN customer gateway", +"message.add.vpn.customer.gateway.processing": "Creation of VPN customer gateway is in progress", "message.add.vpn.gateway": "Please confirm that you want to add a VPN Gateway.", "message.add.vpn.gateway.failed": "Adding VPN gateway failed", "message.add.vpn.gateway.processing": "Adding VPN gateway...", -"message.added.latest.kubernetes.iso": "Latest Kubernetes ISO added successfully", -"message.added.minimum.required.compute.offering.kubernetes.cluster": "Minimum required Compute Offering for Kubernetes cluster nodes added successfully", "message.added.vpc.offering": "Added VPC offering", "message.adding.firewall.policy": "Adding Firewall Policy", "message.adding.host": "Adding host", -"message.adding.latest.kubernetes.iso": "Adding latest Kubernetes ISO", -"message.adding.minimum.required.compute.offering.kubernetes.cluster": "Adding minimum required Compute Offering for Kubernetes cluster nodes", "message.adding.netscaler.device": "Adding Netscaler device", "message.adding.netscaler.provider": "Adding Netscaler provider", "message.adding.nodes.to.cluster": "Adding nodes to Kubernetes cluster", @@ -3191,10 +3136,8 @@ "message.change.offering.confirm": "Please confirm that you wish to change the service offering of this virtual Instance.", "message.change.offering.for.volume": "Successfully changed offering for the volume", "message.change.offering.for.volume.failed": "Change offering for the volume failed", -"message.change.offering.for.volume.processing": "Changing offering for the volume...", +"message.change.offering.processing": "Changing offering for the volume...", "message.change.password": "Please change your password.", -"message.change.password.required": "You are required to change your password.", -"message.change.password.reset": "Force password reset on next login.", "message.change.scope.failed": "Scope change failed", "message.change.scope.processing": "Scope change in progress", "message.change.service.offering.sharedfs.failed": "Failed to change service offering for the Shared FileSystem.", @@ -3283,10 +3226,6 @@ "message.create.bucket.failed": "Failed to create bucket.", "message.create.bucket.processing": "Bucket creation in progress", "message.create.compute.offering": "Compute Offering created", -"message.clone.compute.offering": "Compute Offering cloned", -"message.clone.service.offering": "Service Offering cloned", -"message.clone.offering.from": "Cloning from", -"message.clone.offering.edit.hint": "All values are pre-filled from the source offering. Edit any field to customize the new offering.", "message.create.sharedfs.failed": "Failed to create Shared FileSystem.", "message.create.sharedfs.processing": "Shared FileSystem creation in progress.", "message.create.tungsten.public.network": "Create Tungsten-Fabric public Network", @@ -3302,7 +3241,7 @@ "message.create.volume.failed": "Failed to create Volume.", "message.create.volume.processing": "Volume creation in progress", "message.create.vpc.offering": "VPC offering created.", -"message.create.vpn.customer.gateway.failed": "VPN Customer Gateway creation failed.", +"message.create.vpn.customer.gateway.failed": "VPN customer gateway creation failed.", "message.creating.autoscale.vmgroup": "Creating AutoScaling Group", "message.creating.autoscale.vmprofile": "Creating AutoScale Instance profile", "message.creating.autoscale.scaledown.conditions": "Creating ScaleDown conditions", @@ -3331,9 +3270,6 @@ "message.delete.account.processing": "Deleting account", "message.delete.account.success": "Successfully deleted account", "message.delete.account.warning": "Deleting this account will delete all of the instances, volumes and snapshots associated with the account.", -"message.delete.domain.confirm": "Please confirm that you want to delete this domain by entering the name of the domain below.", -"message.delete.domain.warning": "All associated accounts, users, VMs, and sub-domains will be permanently deleted. This action cannot be undone.", -"message.delete.domain.failed": "Delete domain failed", "message.delete.acl.processing": "Removing ACL rule...", "message.delete.acl.rule": "Remove ACL rule", "message.delete.acl.rule.failed": "Failed to remove ACL rule.", @@ -3356,12 +3292,11 @@ "message.delete.tungsten.tag": "Are you sure you want to remove this Tag from this Policy?", "message.delete.user": "Please confirm that you would like to delete this User.", "message.delete.vpn.connection": "Please confirm that you want to delete VPN connection.", -"message.delete.vpn.customer.gateway": "Please confirm that you want to delete this VPN Customer Gateway.", +"message.delete.vpn.customer.gateway": "Please confirm that you want to delete this VPN customer gateway.", "message.delete.vpn.gateway": "Please confirm that you want to delete this VPN Gateway.", "message.delete.vpn.gateway.failed": "Failed to delete VPN Gateway.", "message.delete.webhook": "Please confirm that you want to delete this Webhook.", "message.delete.webhook.delivery": "Please confirm that you want to delete this Webhook delivery.", -"message.delete.webhook.filter": "Please confirm that you want to delete this Webhook filter.", "message.deleting.firewall.policy": "Deleting Firewall Policy", "message.deleting.node": "Deleting Node", "message.deleting.vm": "Deleting Instance", @@ -3388,7 +3323,7 @@ "message.desc.register.user.data": "Please fill in the following to register new User Data.", "message.desc.registered.user.data": "Registered a User Data.", "message.desc.reset.ssh.key.pair": "Please specify a ssh key pair that you would like to add to this Instance.", -"message.desc.secondary.storage": "Each Zone must have at least one NFS or secondary storage server. We will add the first one now. Secondary storage stores Instance Templates, ISO images, and Instance disk volume Snapshots. This server must be available to all hosts in the zone.

Provide the IP address and exported path.

\"Copy templates from other secondary storages\" switch can be used to automatically copy existing templates from secondary storages in other zones instead of fetching from their URLs.", +"message.desc.secondary.storage": "Each Zone must have at least one NFS or secondary storage server. We will add the first one now. Secondary storage stores Instance Templates, ISO images, and Instance disk volume Snapshots. This server must be available to all hosts in the zone.

Provide the IP address and exported path.", "message.desc.validationformat": "Specifies the format used to validate the parameter value, such as EMAIL, URL, UUID, DECIMAL, etc.", "message.desc.valueoptions": "Provide a comma-separated list of values that will appear as selectable options for this parameter", "message.desc.zone": "A Zone is the largest organizational unit in CloudStack, and it typically corresponds to a single datacenter. Zones provide physical isolation and redundancy. A zone consists of one or more Pods (each of which contains hosts and primary storage servers) and a secondary storage server which is shared by all pods in the zone.", @@ -3407,9 +3342,6 @@ "message.disable.webhook.ssl.verification": "Disabling SSL verification is not recommended", "message.discovering.feature": "Discovering features, please wait...", "message.disk.offering.created": "Disk offering created:", -"message.success.clone.backup.offering": "Successfully cloned backup offering", -"message.success.clone.disk.offering": "Successfully cloned disk offering:", -"message.success.clone.network.offering": "Successfully cloned network offering:", "message.disk.usage.info.data.points": "Each data point represents the difference in read/write data since the last data point.", "message.disk.usage.info.sum.of.disks": "The disk usage shown is made up of the sum of read/write data from all the disks in the Instance.", "message.download.volume": "Please click the link to download the volume:

00000", @@ -3432,7 +3364,6 @@ "message.enable.vpn.processing": "Enabling VPN...", "message.enabled.vpn": "Your remote access VPN is currently enabled and can be accessed via the IP", "message.enabled.vpn.ip.sec": "Your IPSec pre-shared key is", -"message.enabled.vpn.ip.range": "Your VPN IP Range is", "message.enabling.security.group.provider": "Enabling security group provider", "message.enter.valid.nic.ip": "Please enter a valid IP address for NIC", "message.error.access.key": "Please enter access key.", @@ -3452,7 +3383,6 @@ "message.error.apply.tungsten.tag": "Applying Tag failed", "message.error.binaries.iso.url": "Please enter binaries ISO URL.", "message.error.bucket": "Please enter bucket", -"message.error.change.password": "Failed to change password.", "message.error.cidr": "CIDR is required", "message.error.cidr.or.cidrsize": "CIDR or cidr size is required", "message.error.cloudian.console": "Single-Sign-On failed for Cloudian management console. Please ask your administrator to fix integration issues.", @@ -3471,7 +3401,6 @@ "message.error.delete.tungsten.tag": "Removing Tag failed", "message.error.description": "Please enter description.", "message.error.discovering.feature": "Exception caught while discovering features.", -"message.error.setting.deployasistemplate": "Settings are read directly from the template", "message.error.setup.2fa": "2FA setup failed while verifying the code, please retry.", "message.error.verifying.2fa": "Unable to verify 2FA, please retry.", "message.error.display.text": "Please enter display text.", @@ -3516,7 +3445,6 @@ "message.error.netmask": "Please enter Netmask.", "message.error.network.offering": "Please select Network offering.", "message.error.new.password": "Please enter new password.", -"message.error.newpassword.sameascurrent": "New password cannot be the same as the current password.", "message.error.nexus1000v.ipaddress": "Please enter Nexus 1000v IP address.", "message.error.nexus1000v.password": "Please enter Nexus 1000v password.", "message.error.nexus1000v.username": "Please enter Nexus 1000v username.", @@ -3607,8 +3535,6 @@ "message.failed.to.remove": "Failed to remove", "message.forgot.password.success": "An email has been sent to your email address with instructions on how to reset your password.", "message.generate.keys": "Please confirm that you would like to generate new API/Secret keys for this User.", -"message.global.setting.updated": "Global Setting updated successfully.", -"message.global.setting.update.failed": "Failed to update Global Setting.", "message.chart.statistic.info": "The shown charts are self-adjustable, that means, if the value gets close to the limit or overpass it, it will grow to adjust the shown value", "message.chart.statistic.info.hypervisor.additionals": "The metrics data depend on the hypervisor plugin used for each hypervisor. The behavior can vary across different hypervisors. For instance, with KVM, metrics are real-time statistics provided by libvirt. In contrast, with VMware, the metrics are averaged data for a given time interval controlled by configuration.", "message.guest.traffic.in.advanced.zone": "Guest Network traffic is communication between end-user Instances. Specify a range of VLAN IDs or VXLAN Network identifiers (VNIs) to carry guest traffic for each physical Network.", @@ -3724,7 +3650,6 @@ "message.move.acl.order.processing": "Moving ACL rule...", "message.network.acl.default.allow": "Warning: With this policy all traffic will be allowed through the firewall to this VPC Network Tier. You should consider securing your Network.", "message.network.acl.default.deny": "Warning: With this policy all traffic will be denied through the firewall to this VPC Network Tier. In order to allow traffic through you will need to change policies.", -"message.network.acl.import.note": "Note: Only valid rules from the CSV will be imported. Invalid entries will be discarded.", "message.network.addvm.desc": "Please specify the Network that you would like to add this Instance to. A new NIC will be added for this Network.", "message.network.description": "Setup Network and traffic.", "message.network.error": "Network Error", @@ -3742,7 +3667,6 @@ "message.network.selection": "Choose one or more Networks to attach the Instance to.", "message.network.selection.new.network": "A new Network can also be created here.", "message.network.updateip": "Please confirm that you would like to change the IP address for this NIC on the Instance.", -"message.network.update.nic": "Please confirm that you would like to update this NIC.", "message.network.usage.info.data.points": "Each data point represents the difference in data traffic since the last data point.", "message.network.usage.info.sum.of.vnics": "The Network usage shown is made up of the sum of data traffic from all the vNICs in the Instance.", "message.nfs.mount.options.description": "Comma separated list of NFS mount options for KVM hosts. Supported options : vers=[3,4.0,4.1,4.2], nconnect=[1...16]", @@ -3762,7 +3686,6 @@ "message.please.confirm.remove.user.data": "Please confirm that you want to remove this User Data", "message.please.enter.valid.value": "Please enter a valid value.", "message.please.enter.value": "Please enter values.", -"message.please.login.new.password": "Please log in again with your new password", "message.please.wait.while.autoscale.vmgroup.is.being.created": "Please wait while your AutoScaling Group is being created; this may take a while...", "message.please.wait.while.zone.is.being.created": "Please wait while your Zone is being created; this may take a while...", "message.pod.dedicated": "Pod dedicated.", @@ -3785,7 +3708,6 @@ "message.releasing.dedicated.host": "Releasing dedicated host...", "message.releasing.dedicated.pod": "Releasing dedicated Pod...", "message.releasing.dedicated.zone": "Releasing dedicated Zone...", -"message.remote.access.vpn.iprange.description": "The range of IP addresses to allocate to VPN clients. The first IP in the range will be taken by the VPN server. (Optional)", "message.remove.annotation": "Are you sure you want to delete the comment?", "message.remove.egress.rule.failed": "Removing egress rule failed", "message.remove.egress.rule.processing": "Deleting egress rule...", @@ -3819,7 +3741,6 @@ "message.resource.not.found": "Resource not found.", "message.restart.mgmt.server": "Please restart your management server(s) for your new settings to take effect.", "message.restart.network": "All services provided by this Network will be interrupted. Please confirm that you want to restart this Network.", -"message.restart.usage.server": "Please restart your usage server(s) for your new settings to take effect.", "message.restart.vm.to.update.settings": "Update in fields other than name and display name will require the Instance to be restarted.", "message.restart.vpc": "Please confirm that you want to restart the VPC.", "message.restart.vpc.remark": "Please confirm that you want to restart the VPC

Remark: making a non-redundant VPC redundant will force a clean up. The Networks will not be available for a couple of minutes.

", @@ -3909,9 +3830,8 @@ "message.success.add.tungsten.routing.policy": "Successfully added Tungsten-Fabric routing policy", "message.success.add.vpc": "Successfully added a Virtual Private Cloud", "message.success.add.vpc.network": "Successfully added a VPC network", -"message.success.add.vpn.customer.gateway": "Successfully added VPN Customer Gateway", +"message.success.add.vpn.customer.gateway": "Successfully added VPN customer gateway", "message.success.add.vpn.gateway": "Successfully added VPN gateway", -"message.success.add.webhook.filter": "Successfully added Webhook Filter", "message.success.assign.sslcert": "Successfully assigned SSL certificate", "message.success.assign.vm": "Successfully assigned Instance", "message.success.apply.network.policy": "Successfully applied Network Policy", @@ -3924,7 +3844,6 @@ "message.success.change.password": "Successfully changed password for User", "message.success.change.host.password": "Successfully changed password for host \"{name}\"", "message.success.clear.webhook.deliveries": "Successfully cleared webhook deliveries", -"message.success.clear.webhook.filters": "Successfully cleared webhook filters", "message.success.change.scope": "Successfully changed scope for storage pool", "message.success.config.backup.schedule": "Successfully configured Instance backup schedule", "message.success.config.health.monitor": "Successfully Configure Health Monitor", @@ -4026,17 +3945,15 @@ "message.success.delete.vgpu.profile": "Successfully deleted vGPU profile", "message.success.update.custom.action": "Successfully updated Custom Action", "message.success.update.extension": "Successfully updated Extension", +"message.success.update.sharedfs": "Successfully updated Shared FileSystem", "message.success.update.ipaddress": "Successfully updated IP address", "message.success.update.iprange": "Successfully updated IP range", "message.success.update.ipv4.subnet": "Successfully updated IPv4 subnet", "message.success.update.iso": "Successfully updated ISO", "message.success.update.kubeversion": "Successfully updated Kubernetes supported version", "message.success.update.network": "Successfully updated Network", -"message.success.update.nic": "Successfully updated NIC", -"message.success.update.sharedfs": "Successfully updated Shared FileSystem", "message.success.update.template": "Successfully updated Template", "message.success.update.user": "Successfully updated User", -"message.success.update.vpn.customer.gateway": "Successfully updated VPN Customer Gateway", "message.success.upgrade.kubernetes": "Successfully upgraded Kubernetes Cluster", "message.success.upload": "Successfully uploaded", "message.success.upload.description": "This ISO file has been uploaded. Please check its status in the Templates menu.", @@ -4064,9 +3981,6 @@ "message.update.condition.failed": "Failed to update condition", "message.update.condition.processing": "Updating condition...", "message.update.failed": "Update failed", -"message.update.vpn.customer.gateway": "Update VPN Customer Gateway", -"message.update.vpn.customer.gateway.failed": "Updating the VPN Customer Gateway failed", -"message.update.vpn.customer.gateway.processing": "Updating VPN Customer Gateway...", "message.test.webhook.delivery": "Test delivery to the Webhook with an optional payload", "message.two.factor.authorization.failed": "Unable to verify 2FA with provided code, please retry.", "message.two.fa.auth": "Open the two-factor authentication app on your mobile device to view your authentication code.", @@ -4083,7 +3997,6 @@ "message.two.fa.setup.page": "Two factor authentication (2FA) is an extra layer of security to your account.
Once setup is done, on every login you will be prompted to enter the 2FA code.
", "message.two.fa.view.setup.key": "Click here to view the setup key", "message.two.fa.view.static.pin": "Click here to view the static PIN", -"message.update.nic.processing": "Updating NIC...", "message.update.ipaddress.processing": "Updating IP Address...", "message.update.resource.count": "Please confirm that you want to update resource counts for this Account.", "message.update.resource.count.domain": "Please confirm that you want to update resource counts for this domain.", @@ -4131,7 +4044,6 @@ "message.vnf.no.credentials": "No credentials found for the VNF appliance.", "message.vnf.select.networks": "Please select the relevant network for each VNF NIC.", "message.volume.desc": "Volume to use as a ROOT disk", -"message.volume.pool.apply.to.all": "Selected storage pool will be applied to all existing volumes of the instance.", "message.volume.state.allocated": "The volume is allocated but has not been created yet.", "message.volume.state.attaching": "The volume is attaching to a volume from Ready state.", "message.volume.state.copying": "The volume is being copied from the image store to primary storage, in case it's an uploaded volume.", @@ -4155,11 +4067,6 @@ "message.volumes.managed": "Volumes controlled by CloudStack.", "message.volumes.unmanaged": "Volumes not controlled by CloudStack.", "message.vpc.restart.required": "Restart is required for VPC(s). Click here to view VPC(s) which require restart.", -"message.vpn.customer.gateway.contains.excluded.obsolete.parameters": "This VPN Customer Gateway contains cryptographic parameters that are marked as excluded or obsolete by the Administrator. Consider changing them using the Update VPN Customer Gateway form.", -"message.vpn.customer.gateway.excluded.parameter": " is marked as excluded. Please choose another value.", -"message.vpn.customer.gateway.obsolete.parameter": " is marked as obsolete/insecure. Please choose another value.", -"message.vpn.customer.gateway.obsolete.parameter.tooltip": "This parameter value is marked as obsolete/insecure.", -"message.vr.alert.upon.network.offering.creation.l2": "As virtual routers are not created for L2 Networks, the compute offering will not be used.", "message.vr.alert.upon.network.offering.creation.others": "As none of the obligatory services for creating a virtual router (VPN, DHCP, DNS, Firewall, LB, UserData, SourceNat, StaticNat, PortForwarding) are enabled, the virtual router will not be created and the compute offering will not be used.", "message.warn.change.primary.storage.scope": "This feature is tested and supported for the following configurations:
KVM - NFS/Ceph - DefaultPrimary
VMware - NFS - DefaultPrimary
*There might be extra steps involved to make it work for other configurations.", "message.warn.filetype": "jpg, jpeg, png, bmp and svg are the only supported image formats.", diff --git a/ui/src/views/infra/AddObjectStorage.vue b/ui/src/views/infra/AddObjectStorage.vue index 5410a9b9502f..dca99e98dc7c 100644 --- a/ui/src/views/infra/AddObjectStorage.vue +++ b/ui/src/views/infra/AddObjectStorage.vue @@ -31,6 +31,7 @@ +