This commit is contained in:
Muhammed Hussein 2026-03-09 13:14:41 +00:00 committed by GitHub
commit 6045c3016c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 2554 additions and 69 deletions

View File

@ -632,6 +632,11 @@
<artifactId>cloud-plugin-storage-object-minio</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.apache.cloudstack</groupId>
<artifactId>cloud-plugin-storage-object-ecs</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.apache.cloudstack</groupId>
<artifactId>cloud-plugin-storage-object-ceph</artifactId>

View File

@ -140,6 +140,7 @@
<module>storage/object/ceph</module>
<module>storage/object/cloudian</module>
<module>storage/object/simulator</module>
<module>storage/object/ECS</module>
<module>storage-allocators/random</module>

View File

@ -0,0 +1,47 @@
<!--
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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>cloud-plugin-storage-object-ecs</artifactId>
<name>Apache CloudStack Plugin - ECS object storage provider</name>
<parent>
<groupId>org.apache.cloudstack</groupId>
<artifactId>cloudstack-plugins</artifactId>
<version>4.23.0.0-SNAPSHOT</version>
<relativePath>../../../pom.xml</relativePath>
</parent>
<dependencies>
<dependency>
<groupId>org.apache.cloudstack</groupId>
<artifactId>cloud-engine-storage</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.apache.cloudstack</groupId>
<artifactId>cloud-engine-storage-object</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.apache.cloudstack</groupId>
<artifactId>cloud-engine-schema</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,38 @@
/*
* 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;
public final class EcsConstants {
private EcsConstants() {}
// Object store details keys
public static final String MGMT_URL = "mgmt_url";
public static final String SA_USER = "sa_user";
public static final String SA_PASS = "sa_password";
public static final String NAMESPACE = "namespace";
public static final String INSECURE = "insecure";
public static final String S3_HOST = "s3_host";
public static final String USER_PREFIX = "user_prefix";
public static final String DEFAULT_USER_PREFIX = "cs-";
// Per-account keys
public static final String AD_KEY_ACCESS = "ecs.accesskey";
public static final String AD_KEY_SECRET = "ecs.secretkey";
}

View File

@ -0,0 +1,161 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.cloudstack.storage.datastore.driver;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.auth.BasicScheme;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.util.EntityUtils;
import com.cloud.utils.exception.CloudRuntimeException;
public class EcsMgmtTokenManager {
private static final long DEFAULT_TOKEN_MAX_AGE_SEC = 300;
private static final long EXPIRY_SKEW_SEC = 30;
private static final ConcurrentHashMap<TokenKey, TokenEntry> TOKEN_CACHE = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<TokenKey, Object> TOKEN_LOCKS = new ConcurrentHashMap<>();
static final class EcsUnauthorizedException extends RuntimeException {
EcsUnauthorizedException(final String msg) { super(msg); }
}
@FunctionalInterface
public interface WithToken<T> { T run(String token) throws Exception; }
private static final class TokenKey {
final String mgmtUrl;
final String user;
TokenKey(final String mgmtUrl, final String user) {
this.mgmtUrl = mgmtUrl;
this.user = user;
}
@Override public boolean equals(final Object o) {
if (this == o) return true;
if (!(o instanceof TokenKey)) return false;
final TokenKey k = (TokenKey) o;
return Objects.equals(mgmtUrl, k.mgmtUrl) && Objects.equals(user, k.user);
}
@Override public int hashCode() { return Objects.hash(mgmtUrl, user); }
}
private static final class TokenEntry {
final String token;
final long expiresAtMs;
TokenEntry(final String token, final long expiresAtMs) {
this.token = token;
this.expiresAtMs = expiresAtMs;
}
boolean validNow() {
return token != null && !token.isBlank() && System.currentTimeMillis() < expiresAtMs;
}
}
public <T> T callWithRetry401(final EcsObjectStoreDriverImpl.EcsCfg cfg,
final WithToken<T> op,
final HttpClientFactory httpFactory) throws Exception {
try {
return op.run(getAuthToken(cfg, httpFactory));
} catch (EcsUnauthorizedException u) {
invalidate(cfg);
return op.run(getAuthToken(cfg, httpFactory));
}
}
public void invalidate(final EcsObjectStoreDriverImpl.EcsCfg cfg) {
TOKEN_CACHE.remove(new TokenKey(trimTail(cfg.mgmtUrl), cfg.saUser));
}
public String getAuthToken(final EcsObjectStoreDriverImpl.EcsCfg cfg,
final HttpClientFactory httpFactory) {
final String mu = trimTail(cfg.mgmtUrl);
final TokenKey key = new TokenKey(mu, cfg.saUser);
final TokenEntry cached = TOKEN_CACHE.get(key);
if (cached != null && cached.validNow()) return cached.token;
final Object lock = TOKEN_LOCKS.computeIfAbsent(key, k -> new Object());
synchronized (lock) {
final TokenEntry cached2 = TOKEN_CACHE.get(key);
if (cached2 != null && cached2.validNow()) return cached2.token;
final TokenEntry fresh = loginAndGetTokenFresh(mu, cfg.saUser, cfg.saPass, cfg.insecure, httpFactory);
TOKEN_CACHE.put(key, fresh);
return fresh.token;
}
}
private TokenEntry loginAndGetTokenFresh(final String mgmtUrl,
final String user,
final String pass,
final boolean insecure,
final HttpClientFactory httpFactory) {
try (CloseableHttpClient http = httpFactory.build(insecure)) {
final HttpGet get = new HttpGet(mgmtUrl + "/login");
UsernamePasswordCredentials creds = new UsernamePasswordCredentials(user, pass);
get.addHeader(new BasicScheme().authenticate(creds, get, null));
try (CloseableHttpResponse resp = http.execute(get)) {
final int status = resp.getStatusLine().getStatusCode();
if (status != 200 && status != 201) {
final String body = resp.getEntity() != null
? EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8)
: "";
throw new CloudRuntimeException("ECS /login failed: HTTP " + status + " body=" + body);
}
if (resp.getFirstHeader("X-SDS-AUTH-TOKEN") == null) {
throw new CloudRuntimeException("ECS /login did not return X-SDS-AUTH-TOKEN header");
}
final String token = resp.getFirstHeader("X-SDS-AUTH-TOKEN").getValue();
long maxAgeSec = DEFAULT_TOKEN_MAX_AGE_SEC;
try {
if (resp.getFirstHeader("X-SDS-AUTH-MAX-AGE") != null) {
maxAgeSec = Long.parseLong(resp.getFirstHeader("X-SDS-AUTH-MAX-AGE").getValue().trim());
}
} catch (Exception ignore) { }
final long effectiveSec = Math.max(5, maxAgeSec - EXPIRY_SKEW_SEC);
final long expiresAtMs = System.currentTimeMillis() + (effectiveSec * 1000L);
return new TokenEntry(token, expiresAtMs);
}
} catch (Exception e) {
throw new CloudRuntimeException("Failed to obtain ECS auth token: " + e.getMessage(), e);
}
}
private static String trimTail(final String s) {
if (s == null) return null;
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
/** Simple seam for testability; implemented by the driver using its existing buildHttpClient(). */
@FunctionalInterface
public interface HttpClientFactory {
CloseableHttpClient build(boolean insecure);
}
}

View File

@ -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.driver;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class EcsXmlParser {
public Integer parseIntTag(final String xml, final String tag) {
String v = extractTag(xml, tag);
if (v == null) return null;
try { return Integer.parseInt(v.trim()); } catch (NumberFormatException ignore) { return null; }
}
public String extractTag(final String xml, final String tag) {
if (xml == null) return null;
final String open = "<" + tag + ">";
final String close = "</" + tag + ">";
int i = xml.indexOf(open);
if (i < 0) return null;
int j = xml.indexOf(close, i + open.length());
if (j < 0) return null;
return xml.substring(i + open.length(), j).trim();
}
public List<String> extractAllTags(final String xml, final String tag) {
final List<String> out = new ArrayList<>();
if (xml == null) return out;
final String open = "<" + tag + ">";
final String close = "</" + tag + ">";
int from = 0;
while (true) {
int i = xml.indexOf(open, from);
if (i < 0) break;
int j = xml.indexOf(close, i + open.length());
if (j < 0) break;
out.add(xml.substring(i + open.length(), j).trim());
from = j + close.length();
}
return out;
}
public void extractKeysFromListBucketXml(final String xml, final List<String> keys) {
if (xml == null) return;
final String contentsOpen = "<Contents>";
final String contentsClose = "</Contents>";
int from = 0;
while (true) {
int i = xml.indexOf(contentsOpen, from);
if (i < 0) break;
int j = xml.indexOf(contentsClose, i + contentsOpen.length());
if (j < 0) break;
String block = xml.substring(i, j + contentsClose.length());
String key = extractTag(block, "Key");
if (key != null && !key.isEmpty()) keys.add(key.trim());
from = j + contentsClose.length();
}
}
public boolean looksLikeBucketAlreadyExists400(final String respBody) {
final String lb = respBody == null ? "" : respBody.toLowerCase(Locale.ROOT);
return lb.contains("already exist")
|| lb.contains("already_exists")
|| lb.contains("already-exists")
|| lb.contains("name already in use")
|| lb.contains("bucket exists")
|| lb.contains("duplicate");
}
}

View File

@ -0,0 +1,321 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.cloudstack.storage.datastore.lifecycle;
import java.util.HashMap;
import java.util.Map;
import javax.inject.Inject;
import javax.net.ssl.SSLContext;
import org.apache.cloudstack.engine.subsystem.api.storage.ClusterScope;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
import org.apache.cloudstack.engine.subsystem.api.storage.HostScope;
import org.apache.cloudstack.engine.subsystem.api.storage.ZoneScope;
import org.apache.cloudstack.storage.datastore.db.ObjectStoreVO;
import org.apache.cloudstack.storage.datastore.driver.EcsConstants;
import org.apache.cloudstack.storage.object.datastore.ObjectStoreHelper;
import org.apache.cloudstack.storage.object.datastore.ObjectStoreProviderManager;
import org.apache.cloudstack.storage.object.store.lifecycle.ObjectStoreLifeCycle;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet; // change to POST if ECS needs it
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.impl.auth.BasicScheme;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.ssl.SSLContextBuilder;
import org.apache.http.ssl.TrustStrategy;
import com.cloud.agent.api.StoragePoolInfo;
import com.cloud.hypervisor.Hypervisor.HypervisorType;
import com.cloud.utils.exception.CloudRuntimeException;
public class EcsObjectStoreLifeCycleImpl implements ObjectStoreLifeCycle {
private static final Logger LOG = LogManager.getLogger(EcsObjectStoreLifeCycleImpl.class);
private static final String PROVIDER_NAME = "ECS";
@Inject
ObjectStoreHelper objectStoreHelper;
@Inject
ObjectStoreProviderManager objectStoreMgr;
public EcsObjectStoreLifeCycleImpl() {
}
@Override
public DataStore initialize(final Map<String, Object> dsInfos) {
requireInjected();
final String url = getString(dsInfos, "url", true);
final String name = getString(dsInfos, "name", true);
final Long size = getLong(dsInfos, "size"); // optional
final String providerName = getProviderName(dsInfos);
final Map<String, String> details = getDetails(dsInfos); // typed map, no unchecked cast
final EcsConfig cfg = verifyAndNormalize(details);
LOG.info("ECS initialize: provider='{}', name='{}', url='{}', mgmt_url='{}', insecure={}, s3_host='{}', namespace='{}'",
providerName, name, url, cfg.mgmtUrl, cfg.insecure,
details.get(EcsConstants.S3_HOST), details.get(EcsConstants.NAMESPACE));
// Try ECS login up-front so we fail fast on bad config
loginAndGetToken(cfg.mgmtUrl, cfg.saUser, cfg.saPass, cfg.insecure);
// Put canonical values back into details (so DB keeps what we validated)
applyCanonicalDetails(details, cfg);
final Map<String, Object> objectStoreParameters = buildObjectStoreParams(name, url, size, providerName);
try {
LOG.info("ECS: creating ObjectStore in DB: name='{}', provider='{}', url='{}'",
name, providerName, url);
final ObjectStoreVO objectStore = objectStoreHelper.createObjectStore(objectStoreParameters, details);
if (objectStore == null) {
throw new CloudRuntimeException("ECS: createObjectStore returned null");
}
final DataStore store = objectStoreMgr.getObjectStore(objectStore.getId());
if (store == null) {
throw new CloudRuntimeException("ECS: getObjectStore returned null for id=" + objectStore.getId());
}
LOG.info("ECS: object store created: id={}, name='{}'", objectStore.getId(), name);
return store;
} catch (RuntimeException e) {
final String msg = "ECS: failed to persist object store '" + name + "': " + safeMsg(e);
LOG.error(msg, e);
throw new CloudRuntimeException(msg, e);
}
}
@Override
public boolean attachCluster(final DataStore store, final ClusterScope scope) {
return false;
}
@Override
public boolean attachHost(final DataStore store, final HostScope scope, final StoragePoolInfo existingInfo) {
return false;
}
@Override
public boolean attachZone(final DataStore dataStore, final ZoneScope scope, final HypervisorType hypervisorType) {
return false;
}
@Override
public boolean maintain(final DataStore store) {
return false;
}
@Override
public boolean cancelMaintain(final DataStore store) {
return false;
}
@Override
public boolean deleteDataStore(final DataStore store) {
return false;
}
@Override
public boolean migrateToObjectStore(final DataStore store) {
return false;
}
// ---------- helpers ----------
private static final class EcsConfig {
final String mgmtUrl;
final String saUser;
final String saPass;
final boolean insecure;
private EcsConfig(final String mgmtUrl, final String saUser, final String saPass, final boolean insecure) {
this.mgmtUrl = mgmtUrl;
this.saUser = saUser;
this.saPass = saPass;
this.insecure = insecure;
}
}
private void requireInjected() {
if (objectStoreHelper == null) {
throw new CloudRuntimeException("ECS: ObjectStoreHelper is not injected");
}
if (objectStoreMgr == null) {
throw new CloudRuntimeException("ECS: ObjectStoreProviderManager is not injected");
}
}
private static String getString(final Map<String, Object> dsInfos, final String key, final boolean required) {
final Object v = dsInfos.get(key);
final String s = (v == null) ? null : v.toString().trim();
if (required && (s == null || s.isEmpty())) {
throw new CloudRuntimeException("ECS: missing required parameter '" + key + "'");
}
return s;
}
private static Long getLong(final Map<String, Object> dsInfos, final String key) {
final Object v = dsInfos.get(key);
if (v == null) {
return null;
}
if (v instanceof Number) {
return ((Number) v).longValue();
}
try {
return Long.parseLong(v.toString().trim());
} catch (Exception e) {
throw new CloudRuntimeException("ECS: invalid long for '" + key + "': " + v);
}
}
private static String getProviderName(final Map<String, Object> dsInfos) {
final String p = getString(dsInfos, "providerName", false);
return (p == null || p.isEmpty()) ? PROVIDER_NAME : p;
}
private static Map<String, String> getDetails(final Map<String, Object> 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<String, String> out = new HashMap<>();
for (Map.Entry<?, ?> e : raw.entrySet()) {
if (e.getKey() == null) {
continue;
}
final String k = e.getKey().toString();
final String val = e.getValue() == null ? null : e.getValue().toString();
out.put(k, val);
}
return out;
}
private static EcsConfig verifyAndNormalize(final Map<String, String> details) {
final String mgmtUrl = trim(details.get(EcsConstants.MGMT_URL));
final String saUser = safe(details.get(EcsConstants.SA_USER));
final String saPass = safe(details.get(EcsConstants.SA_PASS));
final boolean insecure = Boolean.parseBoolean(details.getOrDefault(EcsConstants.INSECURE, "false"));
verifyRequiredDetail(EcsConstants.MGMT_URL, mgmtUrl);
verifyRequiredDetail(EcsConstants.SA_USER, saUser);
verifyRequiredDetail(EcsConstants.SA_PASS, saPass);
return new EcsConfig(mgmtUrl, saUser, saPass, insecure);
}
private static void verifyRequiredDetail(final String key, final String value) {
if (value == null || value.trim().isEmpty()) {
throw new CloudRuntimeException("ECS: missing required detail '" + key + "'");
}
}
private static void applyCanonicalDetails(final Map<String, String> details, final EcsConfig cfg) {
details.put(EcsConstants.MGMT_URL, cfg.mgmtUrl);
details.put(EcsConstants.SA_USER, cfg.saUser);
details.put(EcsConstants.SA_PASS, cfg.saPass);
details.put(EcsConstants.INSECURE, Boolean.toString(cfg.insecure));
// keep any optional keys already present (S3_HOST, NAMESPACE, etc.)
}
private static Map<String, Object> buildObjectStoreParams(final String name,
final String url,
final Long size,
final String providerName) {
final Map<String, Object> p = new HashMap<>();
p.put("name", name);
p.put("url", url);
p.put("size", size);
p.put("providerName", providerName);
return p;
}
private static String safe(final String v) {
return v == null ? "" : v.trim();
}
private static String trim(final String v) {
if (v == null) {
return null;
}
final String s = v.trim();
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
private static String safeMsg(final Throwable t) {
if (t == null) {
return "unknown";
}
final String m = t.getMessage();
return (m == null || m.isEmpty()) ? t.getClass().getSimpleName() : m;
}
private CloseableHttpClient buildHttpClient(final boolean insecure) {
if (!insecure) {
return HttpClients.createDefault();
}
try {
final TrustStrategy trustAll = (chain, authType) -> true;
final SSLContext sslContext = SSLContextBuilder.create()
.loadTrustMaterial(null, trustAll)
.build();
return HttpClients.custom()
.setSSLContext(sslContext)
.setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE)
.build();
} catch (Exception e) {
throw new CloudRuntimeException("ECS: failed to build HttpClient", e);
}
}
private String loginAndGetToken(final String mgmtUrl, final String user, final String pass, final boolean insecure) {
try (CloseableHttpClient http = buildHttpClient(insecure)) {
final HttpGet get = new HttpGet(mgmtUrl + "/login");
get.addHeader(new BasicScheme().authenticate(
new UsernamePasswordCredentials(user, pass), get, null));
try (CloseableHttpResponse resp = http.execute(get)) {
final int status = resp.getStatusLine().getStatusCode();
if (status != 200 && status != 201) {
throw new CloudRuntimeException("ECS /login failed: HTTP " + status);
}
if (resp.getFirstHeader("X-SDS-AUTH-TOKEN") == null) {
throw new CloudRuntimeException("ECS /login missing X-SDS-AUTH-TOKEN");
}
return resp.getFirstHeader("X-SDS-AUTH-TOKEN").getValue();
}
} catch (Exception e) {
final String msg = "ECS: management login error: " + safeMsg(e);
LOG.error(msg, e);
throw new CloudRuntimeException(msg, e);
}
}
}

View File

@ -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<String, Object> 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<DataStoreProviderType> getTypes() {
Set<DataStoreProviderType> types = new HashSet<>();
types.add(DataStoreProviderType.OBJECT);
return types;
}
}

View File

@ -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

View File

@ -0,0 +1,31 @@
<!--
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.
-->
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context.xsd"
>
<bean id="EcsObjectStoreProviderImpl"
class="org.apache.cloudstack.storage.datastore.provider.EcsObjectStoreProviderImpl" />
</beans>

View File

@ -31,6 +31,7 @@
</template>
<a-input v-model:value="form.name" v-focus="true" />
</a-form-item>
<a-form-item name="provider" ref="provider">
<template #label>
<tooltip-label :title="$t('label.providername')" :tooltip="apiParams.provider.description"/>
@ -51,8 +52,8 @@
</a-select>
</a-form-item>
<!-- Cloudian HyperStore Only Object Store Configuration -->
<div v-if="form.provider === 'Cloudian HyperStore'">
<!-- HyperStore Only Object Store Configuration -->
<a-form-item name="url" ref="url" :label="$t('label.cloudian.admin.url')">
<a-input v-model:value="form.url" placeholder="https://admin-hostname:19443" />
</a-form-item>
@ -67,16 +68,106 @@
<!-- Use secretKey field for the password to make provider shared configuration easier -->
<a-input-password v-model:value="form.secretKey" autocomplete="off"/>
</a-form-item>
<a-form-item name="s3Url" ref="s3Url" :label="$t('label.cloudian.s3.url')" :rules="[{ required: true, message: this.$t('label.required') }]">
<a-form-item
name="s3Url"
ref="s3Url"
:label="$t('label.cloudian.s3.url')"
:rules="[{ required: true, message: $t('label.required') }]"
>
<a-input v-model:value="form.s3Url" placeholder="https://s3-hostname or http://s3-hostname"/>
</a-form-item>
<a-form-item name="iamUrl" ref="iamUrl" :label="$t('label.cloudian.iam.url')" :rules="[{ required: true, message: this.$t('label.required') }]">
<a-form-item
name="iamUrl"
ref="iamUrl"
:label="$t('label.cloudian.iam.url')"
:rules="[{ required: true, message: $t('label.required') }]"
>
<a-input v-model:value="form.iamUrl" placeholder="https://iam-hostname:16443 or http://iam-hostname:16080"/>
</a-form-item>
</div>
<!-- ECS Object Store Configuration -->
<div v-else-if="form.provider === 'ECS'">
<!-- Keep old (existing) user-facing labels/strings here to avoid missing i18n keys -->
<a-form-item name="url" ref="url">
<template #label>
<tooltip-label
:title="'ECS Public URL'"
:tooltip="'The S3-compatible endpoint URL that clients use to connect to ECS'"
/>
</template>
<a-input v-model:value="form.url" placeholder="https://ecs.example.com" />
</a-form-item>
<a-form-item name="mgmtUrl" ref="mgmtUrl" :rules="[{ required: true, message: $t('label.required') }]">
<template #label>
<tooltip-label
:title="'ECS API URL'"
:tooltip="'ECS management API URL'"
/>
</template>
<a-input v-model:value="form.mgmtUrl" placeholder="https://ecs-api.elcld.net" />
</a-form-item>
<a-form-item name="s3Host" ref="s3Host" :rules="[{ required: true, message: $t('label.required') }]">
<template #label>
<tooltip-label
:title="'ECS Private URL'"
:tooltip="'The internal S3 endpoint URL used by CloudStack to communicate with ECS. May be the same as the Public URL.'"
/>
</template>
<a-input v-model:value="form.s3Host" placeholder="ecs.example.com or ecs.example.com:9020" />
</a-form-item>
<a-form-item name="accessKey" ref="accessKey">
<template #label>
<tooltip-label
:title="'ECS service account user'"
:tooltip="'Service account user, e.g. cloudstack'"
/>
</template>
<a-input v-model:value="form.accessKey" placeholder="cloudstack" />
</a-form-item>
<a-form-item name="secretKey" ref="secretKey">
<template #label>
<tooltip-label
:title="'ECS service account password'"
:tooltip="'Service account password (sa_password)'"
/>
</template>
<a-input-password v-model:value="form.secretKey" autocomplete="off" />
</a-form-item>
<a-form-item name="namespace" ref="namespace" :rules="[{ required: true, message: $t('label.required') }]">
<template #label>
<tooltip-label
:title="'Namespace'"
:tooltip="'ECS namespace (namespace), e.g. cloudstack'"
/>
</template>
<a-input v-model:value="form.namespace" placeholder="cloudstack" />
</a-form-item>
<a-form-item name="userPrefix" ref="userPrefix">
<template #label>
<tooltip-label
:title="'User prefix'"
:tooltip="'Prefix used for ECS user creation. Default is cs- (user_prefix). Example: cs-'"
/>
</template>
<a-input v-model:value="form.userPrefix" placeholder="cs-" />
</a-form-item>
<a-form-item name="insecure" ref="insecure">
<a-checkbox v-model:checked="form.insecure">
Allow insecure HTTPS (set insecure=true)
</a-checkbox>
</a-form-item>
</div>
<!-- Non-HyperStore, non-ECS Object Stores -->
<div v-else>
<!-- Non-HyperStore Object Stores -->
<a-form-item name="url" ref="url">
<template #label>
<tooltip-label :title="$t('label.url')" :tooltip="apiParams.url.description"/>
@ -96,6 +187,7 @@
<a-input v-model:value="form.size" />
</a-form-item>
</div>
<div :span="24" class="action-button">
<a-button @click="closeModal">{{ $t('label.cancel') }}</a-button>
<a-button type="primary" ref="submit" @click="handleSubmit">{{ $t('label.ok') }}</a-button>
@ -104,6 +196,7 @@
</a-spin>
</div>
</template>
<script>
import { ref, reactive, toRaw } from 'vue'
import { getAPI } from '@/api'
@ -127,7 +220,7 @@ export default {
inject: ['parentFetchData'],
data () {
return {
providers: ['MinIO', 'Ceph', 'Cloudian HyperStore', 'Simulator'],
providers: ['MinIO', 'Ceph', 'Cloudian HyperStore', 'Simulator', 'ECS'],
zones: [],
loading: false
}
@ -144,7 +237,13 @@ export default {
this.formRef = ref()
this.form = reactive({
provider: 'MinIO',
validateSSL: true
validateSSL: true,
// ECS defaults
mgmtUrl: '',
s3Host: '',
namespace: '',
userPrefix: 'cs-',
insecure: false
})
this.rules = reactive({
url: [{ required: true, message: this.$t('label.required') }],
@ -158,6 +257,72 @@ export default {
closeModal () {
this.$emit('close-action')
},
buildCloudianDetails (data, values) {
data['details[0].key'] = 'accesskey'
data['details[0].value'] = values.accessKey
data['details[1].key'] = 'secretkey'
data['details[1].value'] = values.secretKey
data['details[2].key'] = 'validateSSL'
data['details[2].value'] = values.validateSSL
data['details[3].key'] = 's3Url'
data['details[3].value'] = values.s3Url
data['details[4].key'] = 'iamUrl'
data['details[4].value'] = values.iamUrl
},
buildEcsDetails (data, values) {
data['details[0].key'] = 'mgmt_url'
data['details[0].value'] = values.mgmtUrl
data['details[1].key'] = 's3_host'
data['details[1].value'] = values.s3Host
data['details[2].key'] = 'sa_user'
data['details[2].value'] = values.accessKey
data['details[3].key'] = 'sa_password'
data['details[3].value'] = values.secretKey
data['details[4].key'] = 'namespace'
data['details[4].value'] = values.namespace
data['details[5].key'] = 'user_prefix'
data['details[5].value'] =
values.userPrefix && values.userPrefix.trim() !== ''
? values.userPrefix.trim()
: 'cs-'
data['details[6].key'] = 'insecure'
data['details[6].value'] = values.insecure ? 'true' : 'false'
},
buildGenericDetails (data, values) {
data['details[0].key'] = 'accesskey'
data['details[0].value'] = values.accessKey
data['details[1].key'] = 'secretkey'
data['details[1].value'] = values.secretKey
if (values.size) {
data.size = values.size
}
},
buildDetailsByProvider (data, values) {
const provider = values.provider
if (provider === 'Cloudian HyperStore') {
this.buildCloudianDetails(data, values)
return
}
if (provider === 'ECS') {
this.buildEcsDetails(data, values)
return
}
this.buildGenericDetails(data, values)
},
handleSubmit (e) {
e.preventDefault()
if (this.loading) return
@ -165,27 +330,15 @@ export default {
const formRaw = toRaw(this.form)
const values = this.handleRemoveFields(formRaw)
var data = {
const data = {
name: values.name,
size: values.size
}
var provider = values.provider
data.provider = provider
data.provider = values.provider
data.url = values.url
data['details[0].key'] = 'accesskey'
data['details[0].value'] = values.accessKey
data['details[1].key'] = 'secretkey'
data['details[1].value'] = values.secretKey
if (provider === 'Cloudian HyperStore') {
data['details[2].key'] = 'validateSSL'
data['details[2].value'] = values.validateSSL
data['details[3].key'] = 's3Url'
data['details[3].value'] = values.s3Url
data['details[4].key'] = 'iamUrl'
data['details[4].value'] = values.iamUrl
}
this.buildDetailsByProvider(data, values)
this.loading = true
@ -207,9 +360,10 @@ export default {
this.formRef.value.scrollToField(error.errorFields[0].name)
})
},
addObjectStore (params) {
return new Promise((resolve, reject) => {
getAPI('addObjectStoragePool', params).then(json => {
getAPI('addObjectStoragePool', params).then(() => {
resolve()
}).catch(error => {
reject(error)
@ -219,6 +373,7 @@ export default {
}
}
</script>
<style lang="scss" scoped>
.form-layout {
width: 85vw;

View File

@ -24,10 +24,11 @@
:rules="rules"
layout="vertical"
@finish="handleSubmit"
>
>
<a-form-item name="name" ref="name" :label="$t('label.name')">
<a-input v-model:value="form.name" v-focus="true" />
</a-form-item>
<a-form-item name="objectstore" ref="objectstore" :label="$t('label.object.storage')">
<a-select
v-model:value="form.objectstore"
@ -36,35 +37,40 @@
optionFilterProp="value"
:filterOption="(input, option) => {
return option.value.toLowerCase().indexOf(input.toLowerCase()) >= 0
}" >
<a-select-option :value="objectstore.id" v-for="objectstore in objectstores" :key="objectstore.id" :label="objectstore.name">
}">
<a-select-option
:value="objectstore.id"
v-for="objectstore in objectstores"
:key="objectstore.id"
:label="objectstore.name">
{{ objectstore.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item name="quota" ref="quota" :label="$t('label.quotagib')">
<a-input
v-model:value="form.quota"
:placeholder="$t('label.quota')"/>
:placeholder="$t('label.quota')" />
</a-form-item>
<a-form-item name="encryption" ref="encryption" :label="$t('label.encryption')">
<a-switch
v-model:checked="form.encryption"
:checked="encryption"
@change="val => { encryption = val }"/>
<a-switch v-model:checked="form.encryption" />
</a-form-item>
<a-form-item name="versioning" ref="versioning" :label="$t('label.versioning')">
<a-switch
v-model:checked="form.versioning"
:checked="versioning"
@change="val => { versioning = val }"/>
<a-switch v-model:checked="form.versioning" />
</a-form-item>
<a-form-item name="objectlocking" ref="objectlocking" :label="$t('label.objectlocking')">
<a-switch
v-model:checked="form.objectlocking"
:checked="objectlocking"
@change="val => { objectlocking = val }"/>
<!-- Object Lock is hidden when the selected object store is ECS -->
<a-form-item
v-if="showObjectLocking"
name="objectlocking"
ref="objectlocking"
:label="$t('label.objectlocking')">
<a-switch v-model:checked="form.objectlocking" />
</a-form-item>
<a-form-item name="Bucket Policy" ref="policy" :label="$t('label.bucket.policy')">
<a-select
v-model:value="form.policy"
@ -73,14 +79,15 @@
optionFilterProp="value"
:filterOption="(input, option) => {
return option.value.toLowerCase().indexOf(input.toLowerCase()) >= 0
}" >
}">
<a-select-option
:value="policy"
v-for="(policy,idx) in policyList"
v-for="(policy, idx) in policyList"
:key="idx"
>{{ policy }}</a-select-option>
</a-select>
</a-form-item>
<div :span="24" class="action-button">
<a-button @click="closeModal">{{ $t('label.cancel') }}</a-button>
<a-button type="primary" ref="submit" @click="handleSubmit">{{ $t('label.ok') }}</a-button>
@ -89,6 +96,7 @@
</a-spin>
</div>
</template>
<script>
import { ref, reactive, toRaw } from 'vue'
import { getAPI, postAPI } from '@/api'
@ -110,18 +118,43 @@ export default {
inject: ['parentFetchData'],
data () {
return {
loading: false
loading: false,
objectstores: [],
policyList: ['Public', 'Private']
}
},
computed: {
isEcsObjectStore () {
const selectedId = this.form?.objectstore
const stores = this.objectstores || []
const selected = stores.find(os => os.id === selectedId)
if (!selected) return false
const provider = (selected.providername || '')
.toString()
.toUpperCase()
return provider.includes('ECS')
},
showObjectLocking () {
return !this.isEcsObjectStore
}
},
created () {
this.initForm()
this.policyList = ['Public', 'Private']
this.fetchData()
},
methods: {
initForm () {
this.formRef = ref()
this.form = reactive({
name: '',
objectstore: null,
quota: null,
encryption: false,
versioning: false,
objectlocking: false,
policy: 'Private'
})
this.rules = reactive({
name: [{ required: true, message: this.$t('label.required') }],
@ -153,7 +186,7 @@ export default {
const formRaw = toRaw(this.form)
const values = this.handleRemoveFields(formRaw)
var data = {
const data = {
name: values.name,
objectstorageid: values.objectstore,
quota: values.quota,
@ -179,13 +212,14 @@ export default {
}).finally(() => {
this.loading = false
})
}).catch((error) => {
}).catch(error => {
this.formRef.value.scrollToField(error.errorFields[0].name)
})
}
}
}
</script>
<style lang="scss" scoped>
.form-layout {
width: 85vw;

View File

@ -24,23 +24,41 @@
layout="vertical"
@finish="handleSubmit"
>
<a-form-item name="quota" ref="quota" :label="$t('label.quotagib')">
<a-input
v-model:value="form.quota"
:placeholder="$t('label.quota')"/>
</a-form-item>
<a-form-item name="encryption" ref="encryption" :label="$t('label.encryption')">
<!-- Encryption hidden when object store provider is ECS -->
<a-form-item
v-if="showEncryption"
name="encryption"
ref="encryption"
:label="$t('label.encryption')">
<a-switch
v-model:checked="form.encryption"
:checked="encryption"
@change="val => { encryption = val }"/>
:checked="form.encryption"/>
</a-form-item>
<a-form-item name="versioning" ref="versioning" :label="$t('label.versioning')">
<a-switch
v-model:checked="form.versioning"
:checked="versioning"
@change="val => { versioning = val }"/>
:checked="form.versioning"/>
</a-form-item>
<!-- Object Lock hidden when object store provider is ECS -->
<a-form-item
v-if="showObjectLocking"
name="objectlocking"
ref="objectlocking"
:label="$t('label.objectlocking')">
<a-switch
v-model:checked="form.objectlocking"
:checked="form.objectlocking"/>
</a-form-item>
<a-form-item name="Bucket Policy" ref="policy" :label="$t('label.bucket.policy')">
<a-select
v-model:value="form.policy"
@ -48,8 +66,8 @@
showSearch
optionFilterProp="value"
:filterOption="(input, option) => {
return option.value.toLowerCase().indexOf(input.toLowerCase()) >= 0
}" >
return option.value.toLowerCase().indexOf(input.toLowerCase()) >= 0
}">
<a-select-option
:value="policy"
v-for="(policy,idx) in policyList"
@ -57,13 +75,16 @@
>{{ policy }}</a-select-option>
</a-select>
</a-form-item>
<div :span="24" class="action-button">
<a-button @click="closeModal">{{ $t('label.cancel') }}</a-button>
<a-button :loading="loading" type="primary" ref="submit" @click="handleSubmit">{{ $t('label.ok') }}</a-button>
</div>
</a-form>
</div>
</template>
<script>
import { ref, reactive, toRaw } from 'vue'
import { postAPI } from '@/api'
@ -80,10 +101,28 @@ export default {
},
data () {
return {
offerings: [],
customDiskOffering: false,
loading: false,
customDiskOfferingIops: false
policyList: ['Public', 'Private']
}
},
computed: {
isEcsObjectStore () {
const r = this.resource || {}
const provider = (
r.providername ||
r.objectstoreprovider ||
r.objectStoreProvider ||
r.objectStoreprovider ||
r.provider ||
''
).toString().toUpperCase()
return provider.includes('ECS')
},
showEncryption () {
return !this.isEcsObjectStore
},
showObjectLocking () {
return !this.isEcsObjectStore
}
},
beforeCreate () {
@ -91,15 +130,13 @@ export default {
},
created () {
this.initForm()
this.policyList = ['Public', 'Private']
this.fetchData()
},
methods: {
initForm () {
this.formRef = ref()
this.form = reactive({})
this.rules = reactive({
})
this.rules = reactive({})
},
fetchData () {
this.loading = false
@ -110,7 +147,6 @@ export default {
this.loading = true
Object.keys(this.apiParams).forEach(item => {
const field = this.apiParams[item]
let fieldValue = null
let fieldName = null
if (field.type === 'list' || field.name === 'account') {
@ -118,30 +154,36 @@ export default {
} else {
fieldName = field.name
}
fieldValue = this.resource[fieldName] ? this.resource[fieldName] : null
if (fieldValue) {
const fieldValue = this.resource?.[fieldName]
if (fieldValue !== null && fieldValue !== undefined) {
form[field.name] = fieldValue
}
})
this.loading = false
},
handleSubmit (e) {
if (e?.preventDefault) e.preventDefault()
if (this.loading) return
this.formRef.value.validate().then(() => {
const formRaw = toRaw(this.form)
const values = this.handleRemoveFields(formRaw)
var data = {
const data = {
id: this.resource.id,
quota: values.quota,
encryption: values.encryption,
versioning: values.versioning,
objectlocking: values.objectlocking,
policy: values.policy
}
// Hide + do not send encryption/objectlocking for ECS
if (!this.isEcsObjectStore) {
data.encryption = values.encryption
data.objectlocking = values.objectlocking
}
this.loading = true
postAPI('updateBucket', data).then(response => {
postAPI('updateBucket', data).then(() => {
this.$emit('refresh-data')
this.$notification.success({
message: this.$t('label.bucket.update'),
@ -149,16 +191,15 @@ export default {
})
this.closeModal()
}).catch(error => {
console.log(error)
this.$notification.error({
message: `${this.$t('label.bucket.update')} ${this.$t('label.error')}`,
description: error.response.data.updatebucketresponse.errortext,
description: error.response?.data?.updatebucketresponse?.errortext || 'Error',
duration: 0
})
}).finally(() => {
this.loading = false
})
}).catch((error) => {
}).catch(error => {
this.formRef.value.scrollToField(error.errorFields[0].name)
})
},
@ -169,10 +210,10 @@ export default {
}
}
</script>
<style lang="scss" scoped>
.form-layout {
width: 85vw;
@media (min-width: 760px) {
width: 500px;
}