diff --git a/client/pom.xml b/client/pom.xml index e12e03954828..2b673d7750e9 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -642,6 +642,11 @@ cloud-plugin-storage-object-ceph ${project.version} + + org.apache.cloudstack + cloud-plugin-storage-object-cloudian + ${project.version} + org.apache.cloudstack cloud-plugin-storage-object-simulator diff --git a/plugins/integrations/cloudian/src/main/java/org/apache/cloudstack/cloudian/client/CloudianClient.java b/plugins/integrations/cloudian/src/main/java/org/apache/cloudstack/cloudian/client/CloudianClient.java index 9deddbe38a34..c319ba90a7fc 100644 --- a/plugins/integrations/cloudian/src/main/java/org/apache/cloudstack/cloudian/client/CloudianClient.java +++ b/plugins/integrations/cloudian/src/main/java/org/apache/cloudstack/cloudian/client/CloudianClient.java @@ -18,6 +18,8 @@ package org.apache.cloudstack.cloudian.client; import java.io.IOException; +import java.io.InputStream; +import java.io.PushbackInputStream; import java.net.SocketTimeoutException; import java.security.KeyManagementException; import java.security.KeyStoreException; @@ -28,11 +30,13 @@ import java.util.List; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; import javax.net.ssl.X509TrustManager; import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.utils.security.SSLUtils; +import org.apache.http.HttpEntity; import org.apache.http.HttpHost; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; @@ -56,6 +60,7 @@ import org.apache.http.impl.client.BasicAuthCache; import org.apache.http.impl.client.BasicCredentialsProvider; import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.util.EntityUtils; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.LogManager; @@ -88,7 +93,7 @@ public CloudianClient(final String host, final Integer port, final String scheme .setSocketTimeout(timeout * 1000) .build(); - if (!validateSSlCertificate) { + if (!validateSSlCertificate && "https".equalsIgnoreCase(scheme)) { final SSLContext sslcontext = SSLUtils.getSSLContext(); sslcontext.init(null, new X509TrustManager[]{new TrustAllManager()}, new SecureRandom()); final SSLConnectionSocketFactory factory = new SSLConnectionSocketFactory(sslcontext, NoopHostnameVerifier.INSTANCE); @@ -108,7 +113,9 @@ public CloudianClient(final String host, final Integer port, final String scheme private void checkAuthFailure(final HttpResponse response) { if (response != null && response.getStatusLine().getStatusCode() == HttpStatus.SC_UNAUTHORIZED) { final Credentials credentials = httpContext.getCredentialsProvider().getCredentials(AuthScope.ANY); - logger.error("Cloudian admin API authentication failed, please check Cloudian configuration. Admin auth principal=" + credentials.getUserPrincipal() + ", password=" + credentials.getPassword() + ", API url=" + adminApiUrl); + // Don't dump the actual password in the log, but its useful to know the length perhaps. + final String asteriskPassword = "*".repeat(credentials.getPassword().length()); + logger.error("Cloudian admin API authentication failed, please check Cloudian configuration. Admin auth principal=" + credentials.getUserPrincipal() + ", password=" + asteriskPassword + ", API url=" + adminApiUrl); throw new ServerApiException(ApiErrorCode.UNAUTHORIZED, "Cloudian backend API call unauthorized, please ask your administrator to fix integration issues."); } } @@ -123,15 +130,54 @@ private void checkResponseOK(final HttpResponse response) { } } - private boolean checkEmptyResponse(final HttpResponse response) throws IOException { - return response != null && (response.getStatusLine().getStatusCode() == HttpStatus.SC_NO_CONTENT || - response.getEntity() == null || - response.getEntity().getContent() == null); + /** + * Return true if the response does not have an entity. + * This is not the same thing as an empty body which is different and not detected here. + * The 200 response for example should always return false even if it has no body bytes. + * @param response the response to check + * @return true if status code was 204 or the response does not have an entity. False otherwise. + */ + private boolean noResponseEntity(final HttpResponse response) { + return response != null && (response.getStatusLine().getStatusCode() == HttpStatus.SC_NO_CONTENT || response.getEntity() == null); } - private void checkResponseTimeOut(final Exception e) { + /** + * Throw a specific exception for timeout or a more generic server error. + * This method does not return to the caller and instead always throws an exception. + * @param e IOException (including ClientProtocolException) as thrown by httpClient.execute() + * @throws ServerApiException is always thrown + */ + private void throwTimeoutOrServerException(final IOException e) { if (e instanceof ConnectTimeoutException || e instanceof SocketTimeoutException) { throw new ServerApiException(ApiErrorCode.RESOURCE_UNAVAILABLE_ERROR, "Operation timed out, please try again."); + } else if (e instanceof SSLException) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "SSL Error Connecting to Cloudian Admin Service", e); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "internal error", e); + } + } + + /** + * Return the body content stream only if the body has bytes. + * + * Unfortunately, some of the responses such as listGroups() or listUsers() return + * an empty body instead of returning and empty list. The only way to detect this is + * to try read from the body. This method handles this and will return null if the + * body was empty or a valid stream with the body content otherwise. + * + * @param response the response to check for the body contents. + * @return a valid InputStream or null if the body was empty. + * + * @throws IOException some error reading from the body such as timeout etc. + */ + protected InputStream getNonEmptyContentStream(HttpResponse response) throws IOException { + PushbackInputStream iStream = new PushbackInputStream(response.getEntity().getContent()); + int firstByte=iStream.read(); + if (firstByte == -1) { + return null; + } else { + iStream.unread(firstByte); + return iStream; } } @@ -159,22 +205,110 @@ private HttpResponse post(final String path, final Object item) throws IOExcepti return response; } + /** + * Perform a HTTP PUT operation using the path and optional JSON body item. + * @param path the http path to use + * @param item optional object to send in the body payload. Set to null if no body. + * @return the HttpResponse object + * @throws IOException if the request cannot be executed completely. + * @throws ServerApiException if the request meets 401 unauthorized. + */ private HttpResponse put(final String path, final Object item) throws IOException { - final ObjectMapper mapper = new ObjectMapper(); - final String json = mapper.writeValueAsString(item); - final StringEntity entity = new StringEntity(json); final HttpPut request = new HttpPut(adminApiUrl + path); - request.setHeader("content-type", "application/json"); - request.setEntity(entity); + if (item != null) { + final ObjectMapper mapper = new ObjectMapper(); + final String json = mapper.writeValueAsString(item); + final StringEntity entity = new StringEntity(json); + request.setHeader("content-type", "application/json"); + request.setEntity(entity); + } final HttpResponse response = httpClient.execute(request, httpContext); checkAuthFailure(response); return response; } + //////////////////////////////////////////////////////// + //////////////// Public APIs: Misc ///////////////////// + //////////////////////////////////////////////////////// + + /** + * Get the HyperStore Server Version number. + * + * @return version number + * @throws ServerApiException on non-200 response or timeout + */ + public String getServerVersion() { + logger.debug("Getting server version"); + try { + final HttpResponse response = get("/system/version"); + checkResponseOK(response); + HttpEntity entity = response.getEntity(); + return EntityUtils.toString(entity, "UTF-8"); + } catch (final IOException e) { + logger.error("Failed to get HyperStore system version:", e); + throwTimeoutOrServerException(e); + } + return null; + } + + /** + * Get bucket usage information for a group, a user or a particular bucket. + * + * Note: Bucket Usage Statistics in HyperStore are disabled by default. They + * can be enabled by the HyperStore Administrator by setting of the configuration + * 's3.qos.bucketLevel=true'. + * + * @param groupId the groupId is required (and must exist) + * @param userId the userId is optional (null) and if not set all group users are returned. + * @param bucket the bucket is optional (null). If set, the userId must also be set. + * @return a list of bucket usages (possibly empty). + * @throws ServerApiException on non-200 response such as unknown groupId etc or response issue. + */ + public List getUserBucketUsages(final String groupId, final String userId, final String bucket) { + if (StringUtils.isBlank(groupId) || (StringUtils.isBlank(userId) && !StringUtils.isBlank(bucket))) { + String msg = String.format("Bad parameters groupId=%s userId=%s bucket=%s", groupId, userId, bucket); + logger.error(msg); + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, msg); + } + + logger.debug("Getting bucket usages for groupId={} userId={} bucket={}", groupId, userId, bucket); + StringBuilder cmd = new StringBuilder("/system/bucketusage?groupId="); + cmd.append(groupId); + if (! StringUtils.isBlank(userId)) { + cmd.append("&userId="); + cmd.append(userId); + } + if (! StringUtils.isBlank(bucket)) { + cmd.append("&bucket="); + cmd.append(bucket); + } + + try { + final HttpResponse response = get(cmd.toString()); + checkResponseOK(response); + if (noResponseEntity(response)) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "API error"); + } + // If the groupId exists, this request always returns a proper (possibly empty) list + final ObjectMapper mapper = new ObjectMapper(); + return Arrays.asList(mapper.readValue(response.getEntity().getContent(), CloudianUserBucketUsage[].class)); + } catch (final IOException e) { + logger.error("Failed to get bucket usage stats due to:", e); + throwTimeoutOrServerException(e); + return new ArrayList<>(); // never reached + } + } + //////////////////////////////////////////////////////// //////////////// Public APIs: User ///////////////////// //////////////////////////////////////////////////////// + /** + * Create a new HyperStore user. + * @param user the User object to create. + * @return true if the user was successfully created, false if it exists or there was other non-200 (except 401) response. + * @throws ServerApiException if there was any other issue such as 401 unauthorized or network error. + */ public boolean addUser(final CloudianUser user) { if (user == null) { return false; @@ -185,11 +319,18 @@ public boolean addUser(final CloudianUser user) { return response.getStatusLine().getStatusCode() == HttpStatus.SC_OK; } catch (final IOException e) { logger.error("Failed to add Cloudian user due to:", e); - checkResponseTimeOut(e); + throwTimeoutOrServerException(e); } return false; } + /** + * Get a HyperStore user. + * @param userId the userId + * @param groupId the groupId the user belongs to + * @return CloudianUser if found, null if not found. + * @throws ServerApiException if the is any problem. + */ public CloudianUser listUser(final String userId, final String groupId) { if (StringUtils.isAnyEmpty(userId, groupId)) { return null; @@ -198,18 +339,24 @@ public CloudianUser listUser(final String userId, final String groupId) { try { final HttpResponse response = get(String.format("/user?userId=%s&groupId=%s", userId, groupId)); checkResponseOK(response); - if (checkEmptyResponse(response)) { - return null; + if (noResponseEntity(response)) { + return null; // User not found } final ObjectMapper mapper = new ObjectMapper(); return mapper.readValue(response.getEntity().getContent(), CloudianUser.class); } catch (final IOException e) { logger.error("Failed to list Cloudian user due to:", e); - checkResponseTimeOut(e); + throwTimeoutOrServerException(e); + return null; // never reached } - return null; } + /** + * Return a list of all active HyperStore users in a group. + * @param groupId the target group to list + * @return a possibly empty list of CloudianUser objects. + * @throws ServerApiException if there is any problem or non-200 response. + */ public List listUsers(final String groupId) { if (StringUtils.isEmpty(groupId)) { return new ArrayList<>(); @@ -218,16 +365,20 @@ public List listUsers(final String groupId) { try { final HttpResponse response = get(String.format("/user/list?groupId=%s&userType=all&userStatus=active", groupId)); checkResponseOK(response); - if (checkEmptyResponse(response)) { - return new ArrayList<>(); + if (noResponseEntity(response)) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "API error"); + } + InputStream iStream = getNonEmptyContentStream(response); + if (iStream == null) { + return new ArrayList<>(); // empty body => empty list } final ObjectMapper mapper = new ObjectMapper(); - return Arrays.asList(mapper.readValue(response.getEntity().getContent(), CloudianUser[].class)); + return Arrays.asList(mapper.readValue(iStream, CloudianUser[].class)); } catch (final IOException e) { logger.error("Failed to list Cloudian users due to:", e); - checkResponseTimeOut(e); + throwTimeoutOrServerException(e); + return new ArrayList<>(); // never reached } - return new ArrayList<>(); } public boolean updateUser(final CloudianUser user) { @@ -240,7 +391,7 @@ public boolean updateUser(final CloudianUser user) { return response.getStatusLine().getStatusCode() == HttpStatus.SC_OK; } catch (final IOException e) { logger.error("Failed to update Cloudian user due to:", e); - checkResponseTimeOut(e); + throwTimeoutOrServerException(e); } return false; } @@ -255,11 +406,81 @@ public boolean removeUser(final String userId, final String groupId) { return response.getStatusLine().getStatusCode() == HttpStatus.SC_OK; } catch (final IOException e) { logger.error("Failed to remove Cloudian user due to:", e); - checkResponseTimeOut(e); + throwTimeoutOrServerException(e); } return false; } + /** + * Create a new HyperStore Root credential. + * @param userId the userId + * @param groupId the groupId + * @return the new Credential (should never be null) + * @throws ServerApiException if the request fails or bad parameters given + */ + public CloudianCredential createCredential(final String userId, final String groupId) { + if (StringUtils.isAnyBlank(userId, groupId)) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "API error. Missing user or group"); + } + logger.debug("Creating new credentials for user id={} group id={} ", userId, groupId); + try { + String cmd = String.format("/user/credentials?userId=%s&groupId=%s", userId, groupId); + final HttpResponse response = put(cmd, null); + if (response.getStatusLine().getStatusCode() == HttpStatus.SC_FORBIDDEN) { + String msg = String.format("Maximum credentials reached for user id=%s group id=%s. Consult your HyperStore Administrator", userId, groupId); + logger.error(msg); + throw new ServerApiException(ApiErrorCode.ACCOUNT_RESOURCE_LIMIT_ERROR, msg); + } + checkResponseOK(response); + final ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(response.getEntity().getContent(), CloudianCredential.class); + } catch (final IOException e) { + logger.error("Failed to create credential due to:", e); + throwTimeoutOrServerException(e); + return null; // never reached + } + } + + /** + * Get a list of Root credentials for the given user. + * @param userId Cloudian userId + * @param groupId Cloudian groupId + * @return a potentially empty list of Root CloudianCredentials + * @throws ServerApiException on non-2xx response or timeout + */ + public List listCredentials(final String userId, final String groupId) { + return listCredentials(userId, groupId, true); + } + + /** + * Get a list of credentials for the given user. + * @param userId Cloudian userId + * @param groupId Cloudian groupId + * @param rootOnly true only returns root credentials, false returns IAM credentials also. + * @return a potentially empty list of CloudianCredentials + * @throws ServerApiException on non-2xx response or timeout + */ + public List listCredentials(final String userId, final String groupId, final boolean rootOnly) { + if (StringUtils.isAnyBlank(userId, groupId)) { + return new ArrayList<>(); + } + logger.debug("Listing credentials for Cloudian user id={} group id={}", userId, groupId); + try { + String cmd = String.format("/user/credentials/list?userId=%s&groupId=%s&isRootAccountOnly=%b", userId, groupId, rootOnly); + final HttpResponse response = get(cmd); + checkResponseOK(response); + if (noResponseEntity(response)) { + return new ArrayList<>(); // No credentials to be listed case -> 204 + } + final ObjectMapper mapper = new ObjectMapper(); + return Arrays.asList(mapper.readValue(response.getEntity().getContent(), CloudianCredential[].class)); + } catch (final IOException e) { + logger.error("Failed to list credentials due to:", e); + throwTimeoutOrServerException(e); + return new ArrayList<>(); // never reached + } + } + ///////////////////////////////////////////////////////// //////////////// Public APIs: Group ///////////////////// ///////////////////////////////////////////////////////// @@ -274,11 +495,17 @@ public boolean addGroup(final CloudianGroup group) { return response.getStatusLine().getStatusCode() == HttpStatus.SC_OK; } catch (final IOException e) { logger.error("Failed to add Cloudian group due to:", e); - checkResponseTimeOut(e); + throwTimeoutOrServerException(e); } return false; } + /** + * Get the HyperStore group + * @param groupId the group to return + * @return the group if it exists or null if it does not exist. + * @throws ServerApiException on error + */ public CloudianGroup listGroup(final String groupId) { if (StringUtils.isEmpty(groupId)) { return null; @@ -287,16 +514,16 @@ public CloudianGroup listGroup(final String groupId) { try { final HttpResponse response = get(String.format("/group?groupId=%s", groupId)); checkResponseOK(response); - if (checkEmptyResponse(response)) { - return null; + if (noResponseEntity(response)) { + return null; // Group Not Found } final ObjectMapper mapper = new ObjectMapper(); return mapper.readValue(response.getEntity().getContent(), CloudianGroup.class); } catch (final IOException e) { logger.error("Failed to list Cloudian group due to:", e); - checkResponseTimeOut(e); + throwTimeoutOrServerException(e); + return null; // never reached } - return null; } public List listGroups() { @@ -304,16 +531,20 @@ public List listGroups() { try { final HttpResponse response = get("/group/list"); checkResponseOK(response); - if (checkEmptyResponse(response)) { - return new ArrayList<>(); + if (noResponseEntity(response)) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "API error"); + } + InputStream iStream = getNonEmptyContentStream(response); + if (iStream == null) { + return new ArrayList<>(); // Empty body => empty list } final ObjectMapper mapper = new ObjectMapper(); - return Arrays.asList(mapper.readValue(response.getEntity().getContent(), CloudianGroup[].class)); + return Arrays.asList(mapper.readValue(iStream, CloudianGroup[].class)); } catch (final IOException e) { logger.error("Failed to list Cloudian groups due to:", e); - checkResponseTimeOut(e); + throwTimeoutOrServerException(e); + return new ArrayList<>(); // never reached } - return new ArrayList<>(); } public boolean updateGroup(final CloudianGroup group) { @@ -326,7 +557,7 @@ public boolean updateGroup(final CloudianGroup group) { return response.getStatusLine().getStatusCode() == HttpStatus.SC_OK; } catch (final IOException e) { logger.error("Failed to update group due to:", e); - checkResponseTimeOut(e); + throwTimeoutOrServerException(e); } return false; } @@ -341,7 +572,7 @@ public boolean removeGroup(final String groupId) { return response.getStatusLine().getStatusCode() == HttpStatus.SC_OK; } catch (final IOException e) { logger.error("Failed to remove group due to:", e); - checkResponseTimeOut(e); + throwTimeoutOrServerException(e); } return false; } diff --git a/plugins/integrations/cloudian/src/main/java/org/apache/cloudstack/cloudian/client/CloudianCredential.java b/plugins/integrations/cloudian/src/main/java/org/apache/cloudstack/cloudian/client/CloudianCredential.java new file mode 100644 index 000000000000..7d5c9924b429 --- /dev/null +++ b/plugins/integrations/cloudian/src/main/java/org/apache/cloudstack/cloudian/client/CloudianCredential.java @@ -0,0 +1,88 @@ +// 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.cloudian.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.Date; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class CloudianCredential { + + String accessKey; + Boolean active; + Date createDate; + Date expireDate; + String secretKey; + + @Override + public String toString() { + return String.format("Cloudian Credential [ak=%s, sk=***, createDate=%s, expireDate=%s, active=%s]", accessKey, createDate, expireDate, active); + } + + public String getAccessKey() { + return accessKey; + } + + public void setAccessKey(String accessKey) { + this.accessKey = accessKey; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + public Date getCreateDate() { + return createDate; + } + + public void setCreateDate(Date createDate) { + this.createDate = createDate; + } + + public Date getExpireDate() { + return expireDate; + } + + public void setExpireDate(Date expireDate) { + this.expireDate = expireDate; + } + + public Boolean getActive() { + return active; + } + + public void setActive(Boolean active) { + this.active = active; + } + + /** + * Return true if this credential is newer than the other credential. + * @param other the credential to compare against + * @return true only if it is known to be newer, false if anything is null. + */ + public boolean isNewerThan(CloudianCredential other) { + if (this.createDate == null || other == null || other.createDate == null) { + return false; + } + return this.createDate.after(other.createDate); + } +} diff --git a/plugins/integrations/cloudian/src/main/java/org/apache/cloudstack/cloudian/client/CloudianUserBucketUsage.java b/plugins/integrations/cloudian/src/main/java/org/apache/cloudstack/cloudian/client/CloudianUserBucketUsage.java new file mode 100644 index 000000000000..1301bec4b11c --- /dev/null +++ b/plugins/integrations/cloudian/src/main/java/org/apache/cloudstack/cloudian/client/CloudianUserBucketUsage.java @@ -0,0 +1,106 @@ +// 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.cloudian.client; + +import java.util.List; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class CloudianUserBucketUsage { + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class CloudianBucketUsage { + private String bucketName; + private Long byteCount; + private Long objectCount; + private String policyName; + + /** + * Get the name of the bucket the usage stats belong to + * @return the bucket name + */ + public String getBucketName() { + return bucketName; + } + public void setBucketName(String bucketName) { + this.bucketName = bucketName; + } + + /** + * Get the number of bytes used by this bucket. + * + * Note: This size includes bucket and object metadata. + * + * @return bytes used by the bucket + */ + public Long getByteCount() { + return byteCount; + } + public void setByteCount(Long byteCount) { + this.byteCount = byteCount; + } + + /** + * Get the number of objects stored in the bucket. + * + * @return object count in the bucket + */ + public Long getObjectCount() { + return objectCount; + } + public void setObjectCount(Long objectCount) { + this.objectCount = objectCount; + } + + /** + * Get the storage policy this bucket belongs to. + * @return the name of the HyperStore storage policy. + */ + public String getPolicyName() { + return policyName; + } + public void setPolicyName(String policyName) { + this.policyName = policyName; + } + } + + private String userId; + private List buckets; + + /** + * Get the HyperStore userId this usage info belongs to + * @return the HyperStore userId + */ + public String getUserId() { + return userId; + } + public void setUserId(String userId) { + this.userId = userId; + } + + /** + * Get the list of bucket usage objects belonging to this HyperStore userId. + * @return list of bucket usage objects. + */ + public List getBuckets() { + return buckets; + } + public void setBuckets(List buckets) { + this.buckets = buckets; + } +} diff --git a/plugins/integrations/cloudian/src/test/java/org/apache/cloudstack/cloudian/CloudianClientTest.java b/plugins/integrations/cloudian/src/test/java/org/apache/cloudstack/cloudian/CloudianClientTest.java deleted file mode 100644 index fc9a54d1ba62..000000000000 --- a/plugins/integrations/cloudian/src/test/java/org/apache/cloudstack/cloudian/CloudianClientTest.java +++ /dev/null @@ -1,416 +0,0 @@ -// 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.cloudian; - -import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; -import static com.github.tomakehurst.wiremock.client.WireMock.containing; -import static com.github.tomakehurst.wiremock.client.WireMock.delete; -import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor; -import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; -import static com.github.tomakehurst.wiremock.client.WireMock.get; -import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; -import static com.github.tomakehurst.wiremock.client.WireMock.post; -import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; -import static com.github.tomakehurst.wiremock.client.WireMock.put; -import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; -import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; -import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; -import static com.github.tomakehurst.wiremock.client.WireMock.verify; - -import java.util.List; - -import org.apache.cloudstack.api.ServerApiException; -import org.apache.cloudstack.cloudian.client.CloudianClient; -import org.apache.cloudstack.cloudian.client.CloudianGroup; -import org.apache.cloudstack.cloudian.client.CloudianUser; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; - -import com.cloud.utils.exception.CloudRuntimeException; -import com.github.tomakehurst.wiremock.client.BasicCredentials; -import com.github.tomakehurst.wiremock.junit.WireMockRule; - -public class CloudianClientTest { - private final int port = 14333; - private final int timeout = 2; - private final String adminUsername = "admin"; - private final String adminPassword = "public"; - private CloudianClient client; - - @Rule - public WireMockRule wireMockRule = new WireMockRule(port); - - @Before - public void setUp() throws Exception { - client = new CloudianClient("localhost", port, "http", adminUsername, adminPassword, false, timeout); - } - - private CloudianUser getTestUser() { - final CloudianUser user = new CloudianUser(); - user.setActive(true); - user.setUserId("someUserId"); - user.setGroupId("someGroupId"); - user.setUserType(CloudianUser.USER); - user.setFullName("John Doe"); - return user; - } - - private CloudianGroup getTestGroup() { - final CloudianGroup group = new CloudianGroup(); - group.setActive(true); - group.setGroupId("someGroupId"); - group.setGroupName("someGroupName"); - return group; - } - - //////////////////////////////////////////////////////// - //////////////// General API tests ///////////////////// - //////////////////////////////////////////////////////// - - @Test(expected = CloudRuntimeException.class) - public void testRequestTimeout() { - wireMockRule.stubFor(get(urlEqualTo("/group/list")) - .willReturn(aResponse() - .withHeader("content-type", "application/json") - .withStatus(200) - .withFixedDelay(2 * timeout * 1000) - .withBody(""))); - client.listGroups(); - } - - @Test - public void testBasicAuth() { - wireMockRule.stubFor(get(urlEqualTo("/group/list")) - .willReturn(aResponse() - .withStatus(200) - .withBody("[]"))); - client.listGroups(); - verify(getRequestedFor(urlEqualTo("/group/list")) - .withBasicAuth(new BasicCredentials(adminUsername, adminPassword))); - } - - @Test(expected = ServerApiException.class) - public void testBasicAuthFailure() { - wireMockRule.stubFor(get(urlPathMatching("/user")) - .willReturn(aResponse() - .withStatus(401) - .withBody(""))); - client.listUser("someUserId", "somegGroupId"); - } - - ///////////////////////////////////////////////////// - //////////////// User API tests ///////////////////// - ///////////////////////////////////////////////////// - - @Test - public void addUserAccount() { - wireMockRule.stubFor(put(urlEqualTo("/user")) - .willReturn(aResponse() - .withStatus(200) - .withBody(""))); - - final CloudianUser user = getTestUser(); - boolean result = client.addUser(user); - Assert.assertTrue(result); - verify(putRequestedFor(urlEqualTo("/user")) - .withRequestBody(containing("userId\":\"" + user.getUserId())) - .withHeader("content-type", equalTo("application/json"))); - } - - @Test - public void addUserAccountFail() { - wireMockRule.stubFor(put(urlEqualTo("/user")) - .willReturn(aResponse() - .withStatus(400) - .withBody(""))); - - final CloudianUser user = getTestUser(); - boolean result = client.addUser(user); - Assert.assertFalse(result); - } - - @Test - public void listUserAccount() { - final String userId = "someUser"; - final String groupId = "someGroup"; - wireMockRule.stubFor(get(urlPathMatching("/user?.*")) - .willReturn(aResponse() - .withHeader("content-type", "application/json") - .withBody("{\"userId\":\"someUser\",\"userType\":\"User\",\"fullName\":\"John Doe (jdoe)\",\"emailAddr\":\"j@doe.com\",\"address1\":null,\"address2\":null,\"city\":null,\"state\":null,\"zip\":null,\"country\":null,\"phone\":null,\"groupId\":\"someGroup\",\"website\":null,\"active\":\"true\",\"canonicalUserId\":\"b3940886468689d375ebf8747b151c37\",\"ldapEnabled\":false}"))); - - final CloudianUser user = client.listUser(userId, groupId); - Assert.assertEquals(user.getActive(), true); - Assert.assertEquals(user.getUserId(), userId); - Assert.assertEquals(user.getGroupId(), groupId); - Assert.assertEquals(user.getUserType(), "User"); - } - - @Test - public void listUserAccountFail() { - wireMockRule.stubFor(get(urlPathMatching("/user?.*")) - .willReturn(aResponse() - .withHeader("content-type", "application/json") - .withBody(""))); - - final CloudianUser user = client.listUser("abc", "xyz"); - Assert.assertNull(user); - } - - @Test - public void listUserAccounts() { - final String groupId = "someGroup"; - wireMockRule.stubFor(get(urlPathMatching("/user/list?.*")) - .willReturn(aResponse() - .withHeader("content-type", "application/json") - .withBody("[{\"userId\":\"someUser\",\"userType\":\"User\",\"fullName\":\"John Doe (jdoe)\",\"emailAddr\":\"j@doe.com\",\"address1\":null,\"address2\":null,\"city\":null,\"state\":null,\"zip\":null,\"country\":null,\"phone\":null,\"groupId\":\"someGroup\",\"website\":null,\"active\":\"true\",\"canonicalUserId\":\"b3940886468689d375ebf8747b151c37\",\"ldapEnabled\":false}]"))); - - final List users = client.listUsers(groupId); - Assert.assertEquals(users.size(), 1); - Assert.assertEquals(users.get(0).getActive(), true); - Assert.assertEquals(users.get(0).getGroupId(), groupId); - } - - @Test - public void testEmptyListUsersResponse() { - wireMockRule.stubFor(get(urlPathMatching("/user/list")) - .willReturn(aResponse() - .withHeader("content-type", "application/json") - .withStatus(204) - .withBody(""))); - Assert.assertTrue(client.listUsers("someGroup").size() == 0); - - wireMockRule.stubFor(get(urlPathMatching("/user")) - .willReturn(aResponse() - .withHeader("content-type", "application/json") - .withStatus(204) - .withBody(""))); - Assert.assertNull(client.listUser("someUserId", "someGroupId")); - } - - @Test - public void listUserAccountsFail() { - wireMockRule.stubFor(get(urlPathMatching("/user/list?.*")) - .willReturn(aResponse() - .withHeader("content-type", "application/json") - .withBody(""))); - - final List users = client.listUsers("xyz"); - Assert.assertEquals(users.size(), 0); - } - - @Test - public void updateUserAccount() { - wireMockRule.stubFor(post(urlEqualTo("/user")) - .willReturn(aResponse() - .withStatus(200) - .withBody(""))); - - final CloudianUser user = getTestUser(); - boolean result = client.updateUser(user); - Assert.assertTrue(result); - verify(postRequestedFor(urlEqualTo("/user")) - .withRequestBody(containing("userId\":\"" + user.getUserId())) - .withHeader("content-type", equalTo("application/json"))); - } - - @Test - public void updateUserAccountFail() { - wireMockRule.stubFor(post(urlEqualTo("/user")) - .willReturn(aResponse() - .withStatus(400) - .withBody(""))); - - boolean result = client.updateUser(getTestUser()); - Assert.assertFalse(result); - } - - @Test - public void removeUserAccount() { - wireMockRule.stubFor(delete(urlPathMatching("/user.*")) - .willReturn(aResponse() - .withStatus(200) - .withBody(""))); - final CloudianUser user = getTestUser(); - boolean result = client.removeUser(user.getUserId(), user.getGroupId()); - Assert.assertTrue(result); - verify(deleteRequestedFor(urlPathMatching("/user.*")) - .withQueryParam("userId", equalTo(user.getUserId()))); - } - - @Test - public void removeUserAccountFail() { - wireMockRule.stubFor(delete(urlPathMatching("/user.*")) - .willReturn(aResponse() - .withStatus(400) - .withBody(""))); - final CloudianUser user = getTestUser(); - boolean result = client.removeUser(user.getUserId(), user.getGroupId()); - Assert.assertFalse(result); - } - - ////////////////////////////////////////////////////// - //////////////// Group API tests ///////////////////// - ////////////////////////////////////////////////////// - - @Test - public void addGroup() { - wireMockRule.stubFor(put(urlEqualTo("/group")) - .willReturn(aResponse() - .withStatus(200) - .withBody(""))); - - final CloudianGroup group = getTestGroup(); - boolean result = client.addGroup(group); - Assert.assertTrue(result); - verify(putRequestedFor(urlEqualTo("/group")) - .withRequestBody(containing("groupId\":\"someGroupId")) - .withHeader("content-type", equalTo("application/json"))); - } - - @Test - public void addGroupFail() throws Exception { - wireMockRule.stubFor(put(urlEqualTo("/group")) - .willReturn(aResponse() - .withStatus(400) - .withBody(""))); - - final CloudianGroup group = getTestGroup(); - boolean result = client.addGroup(group); - Assert.assertFalse(result); - } - - @Test - public void listGroup() { - final String groupId = "someGroup"; - wireMockRule.stubFor(get(urlPathMatching("/group.*")) - .willReturn(aResponse() - .withHeader("content-type", "application/json") - .withBody("{\"groupId\":\"someGroup\",\"groupName\":\"/someDomain\",\"ldapGroup\":null,\"active\":\"true\",\"ldapEnabled\":false,\"ldapServerURL\":null,\"ldapUserDNTemplate\":null,\"ldapSearch\":null,\"ldapSearchUserBase\":null,\"ldapMatchAttribute\":null}"))); - - final CloudianGroup group = client.listGroup(groupId); - Assert.assertEquals(group.getActive(), true); - Assert.assertEquals(group.getGroupId(), groupId); - } - - @Test - public void listGroupFail() { - wireMockRule.stubFor(get(urlPathMatching("/group.*")) - .willReturn(aResponse() - .withHeader("content-type", "application/json") - .withBody(""))); - - final CloudianGroup group = client.listGroup("xyz"); - Assert.assertNull(group); - } - - @Test - public void listGroups() { - final String groupId = "someGroup"; - wireMockRule.stubFor(get(urlEqualTo("/group/list")) - .willReturn(aResponse() - .withHeader("content-type", "application/json") - .withBody("[{\"groupId\":\"someGroup\",\"groupName\":\"/someDomain\",\"ldapGroup\":null,\"active\":\"true\",\"ldapEnabled\":false,\"ldapServerURL\":null,\"ldapUserDNTemplate\":null,\"ldapSearch\":null,\"ldapSearchUserBase\":null,\"ldapMatchAttribute\":null}]"))); - - final List groups = client.listGroups(); - Assert.assertEquals(groups.size(), 1); - Assert.assertEquals(groups.get(0).getActive(), true); - Assert.assertEquals(groups.get(0).getGroupId(), groupId); - } - - @Test - public void listGroupsFail() { - wireMockRule.stubFor(get(urlEqualTo("/group/list")) - .willReturn(aResponse() - .withHeader("content-type", "application/json") - .withBody(""))); - - final List groups = client.listGroups(); - Assert.assertEquals(groups.size(), 0); - } - - @Test - public void testEmptyListGroupResponse() { - wireMockRule.stubFor(get(urlEqualTo("/group/list")) - .willReturn(aResponse() - .withHeader("content-type", "application/json") - .withStatus(204) - .withBody(""))); - - Assert.assertTrue(client.listGroups().size() == 0); - - - wireMockRule.stubFor(get(urlPathMatching("/group")) - .willReturn(aResponse() - .withHeader("content-type", "application/json") - .withStatus(204) - .withBody(""))); - Assert.assertNull(client.listGroup("someGroup")); - } - - @Test - public void updateGroup() { - wireMockRule.stubFor(post(urlEqualTo("/group")) - .willReturn(aResponse() - .withStatus(200) - .withBody(""))); - - final CloudianGroup group = getTestGroup(); - boolean result = client.updateGroup(group); - Assert.assertTrue(result); - verify(postRequestedFor(urlEqualTo("/group")) - .withRequestBody(containing("groupId\":\"" + group.getGroupId())) - .withHeader("content-type", equalTo("application/json"))); - } - - @Test - public void updateGroupFail() { - wireMockRule.stubFor(post(urlEqualTo("/group")) - .willReturn(aResponse() - .withStatus(400) - .withBody(""))); - - boolean result = client.updateGroup(getTestGroup()); - Assert.assertFalse(result); - } - - @Test - public void removeGroup() { - wireMockRule.stubFor(delete(urlPathMatching("/group.*")) - .willReturn(aResponse() - .withStatus(200) - .withBody(""))); - final CloudianGroup group = getTestGroup(); - boolean result = client.removeGroup(group.getGroupId()); - Assert.assertTrue(result); - verify(deleteRequestedFor(urlPathMatching("/group.*")) - .withQueryParam("groupId", equalTo(group.getGroupId()))); - } - - @Test - public void removeGroupFail() { - wireMockRule.stubFor(delete(urlPathMatching("/group.*")) - .willReturn(aResponse() - .withStatus(400) - .withBody(""))); - final CloudianGroup group = getTestGroup(); - boolean result = client.removeGroup(group.getGroupId()); - Assert.assertFalse(result); - } -} diff --git a/plugins/integrations/cloudian/src/test/java/org/apache/cloudstack/cloudian/CloudianUtilsTest.java b/plugins/integrations/cloudian/src/test/java/org/apache/cloudstack/cloudian/CloudianUtilsTest.java index 4bc9ce1fe284..b5d1588ccd58 100644 --- a/plugins/integrations/cloudian/src/test/java/org/apache/cloudstack/cloudian/CloudianUtilsTest.java +++ b/plugins/integrations/cloudian/src/test/java/org/apache/cloudstack/cloudian/CloudianUtilsTest.java @@ -37,7 +37,7 @@ public void testGenerateSSOUrl() { // test expectations final String expPath = "/Cloudian/ssosecurelogin.htm"; - HashMap expected = new HashMap(); + HashMap expected = new HashMap(); expected.put("user", user); expected.put("group", group); expected.put("timestamp", null); // null value will not be checked by this test diff --git a/plugins/integrations/cloudian/src/test/java/org/apache/cloudstack/cloudian/client/CloudianClientTest.java b/plugins/integrations/cloudian/src/test/java/org/apache/cloudstack/cloudian/client/CloudianClientTest.java new file mode 100644 index 000000000000..74474edc89b7 --- /dev/null +++ b/plugins/integrations/cloudian/src/test/java/org/apache/cloudstack/cloudian/client/CloudianClientTest.java @@ -0,0 +1,790 @@ +// 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.cloudian.client; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; + +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.cloudian.client.CloudianUserBucketUsage.CloudianBucketUsage; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.utils.exception.CloudRuntimeException; +import com.github.tomakehurst.wiremock.client.BasicCredentials; +import com.github.tomakehurst.wiremock.junit.WireMockRule; + +@RunWith(MockitoJUnitRunner.class) +public class CloudianClientTest { + private final int port = 14333; + private final int timeout = 2; + private final String adminUsername = "admin"; + private final String adminPassword = "public"; + private CloudianClient client; + + @Rule + public WireMockRule wireMockRule = new WireMockRule(port); + + @Before + public void setUp() throws Exception { + client = new CloudianClient("localhost", port, "http", adminUsername, adminPassword, false, timeout); + } + + private CloudianUser getTestUser() { + final CloudianUser user = new CloudianUser(); + user.setActive(true); + user.setUserId("someUserId"); + user.setGroupId("someGroupId"); + user.setUserType(CloudianUser.USER); + user.setFullName("John Doe"); + return user; + } + + private CloudianGroup getTestGroup() { + final CloudianGroup group = new CloudianGroup(); + group.setActive(true); + group.setGroupId("someGroupId"); + group.setGroupName("someGroupName"); + return group; + } + + //////////////////////////////////////////////////////// + //////////////// General API tests ///////////////////// + //////////////////////////////////////////////////////// + + @Test(expected = CloudRuntimeException.class) + public void testRequestTimeout() { + wireMockRule.stubFor(get(urlEqualTo("/group/list")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withStatus(200) + .withFixedDelay(2 * timeout * 1000) + .withBody(""))); + client.listGroups(); + } + + @Test + public void testBasicAuth() { + wireMockRule.stubFor(get(urlEqualTo("/group/list")) + .willReturn(aResponse() + .withStatus(200) + .withBody("[]"))); + client.listGroups(); + verify(getRequestedFor(urlEqualTo("/group/list")) + .withBasicAuth(new BasicCredentials(adminUsername, adminPassword))); + } + + @Test(expected = ServerApiException.class) + public void testBasicAuthFailure() { + wireMockRule.stubFor(get(urlPathMatching("/user")) + .willReturn(aResponse() + .withStatus(401) + .withBody(""))); + client.listUser("someUserId", "somegGroupId"); + } + + @Test + public void getNonEmptyContentStreamEmpty() { + InputStream emptyStream = new ByteArrayInputStream(new byte[]{}); + HttpEntity entity = mock(HttpEntity.class); + HttpResponse response = mock(HttpResponse.class); + when(response.getEntity()).thenReturn(entity); + try { + when(entity.getContent()).thenReturn(emptyStream); + Assert.assertNull(client.getNonEmptyContentStream(response)); + } catch (IOException e) { + Assert.fail("Should not be any exception here"); + } + } + + @Test + public void getNonEmptyContentStreamWithContent() { + InputStream nonEmptyStream = new ByteArrayInputStream(new byte[]{9, 8}); + HttpEntity entity = mock(HttpEntity.class); + HttpResponse response = mock(HttpResponse.class); + when(response.getEntity()).thenReturn(entity); + try { + when(entity.getContent()).thenReturn(nonEmptyStream); + InputStream is = client.getNonEmptyContentStream(response); + Assert.assertNotNull(is); + Assert.assertEquals(9, is.read()); + Assert.assertEquals(8, is.read()); + Assert.assertEquals(-1, is.read()); + } catch (IOException e) { + Assert.fail("Should not be any exception here"); + } + } + + ///////////////////////////////////////////////////// + //////////////// System API tests /////////////////// + ///////////////////////////////////////////////////// + + @Test + public void getServerVersion() { + final String expect = "8.1 Compiled: 2023-11-11 16:30"; + wireMockRule.stubFor(get(urlEqualTo("/system/version")) + .willReturn(aResponse() + .withStatus(200) + .withBody(expect))); + + String version = client.getServerVersion(); + Assert.assertEquals(expect, version); + } + + @Test + public void getUserBucketUsagesBadUsageBlankGroup() { + ServerApiException thrown = Assert.assertThrows(ServerApiException.class, () -> client.getUserBucketUsages(null, null, null)); + Assert.assertNotNull(thrown); + Assert.assertEquals(ApiErrorCode.PARAM_ERROR, thrown.getErrorCode()); + } + + @Test + public void getUserBucketUsagesBadUsageBlankUserWithBucket() { + ServerApiException thrown = Assert.assertThrows(ServerApiException.class, () -> client.getUserBucketUsages("group", "", "bucket")); + Assert.assertNotNull(thrown); + Assert.assertEquals(ApiErrorCode.PARAM_ERROR, thrown.getErrorCode()); + } + + @Test + public void getUserBucketUsagesEmptyGroup() { + wireMockRule.stubFor(get(urlEqualTo("/system/bucketusage?groupId=mygroup")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withStatus(200) + .withBody("[]"))); + List bucketUsages = client.getUserBucketUsages("mygroup", null, null); + Assert.assertEquals(0, bucketUsages.size()); + } + + @Test(expected = CloudRuntimeException.class) + public void getUserBucketUsagesNoSuchGroup() { + // no group, no user, no bucket etc are all 400 + wireMockRule.stubFor(get(urlEqualTo("/system/bucketusage?groupId=mygroup")) + .willReturn(aResponse() + .withStatus(400) + .withBody(""))); + client.getUserBucketUsages("mygroup", null, null); + Assert.fail("The request should throw an exception"); + } + + @Test + public void getUserBucketUsagesUserNoBuckets() { + wireMockRule.stubFor(get(urlEqualTo("/system/bucketusage?groupId=mygroup&userId=u1")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withStatus(200) + .withBody("[{\"userId\": \"u1\", \"buckets\": []}]"))); + List bucketUsages = client.getUserBucketUsages("mygroup", "u1", null); + Assert.assertEquals(1, bucketUsages.size()); + CloudianUserBucketUsage u1 = bucketUsages.get(0); + Assert.assertEquals("u1", u1.getUserId()); + Assert.assertEquals(0, u1.getBuckets().size()); + } + + @Test + public void getUserBucketUsagesForBucket() { + wireMockRule.stubFor(get(urlEqualTo("/system/bucketusage?groupId=mygroup&userId=u1&bucket=b1")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withStatus(200) + .withBody("[{\"userId\": \"u1\", \"buckets\": [{\"bucketName\":\"b1\",\"objectCount\":1,\"byteCount\":5,\"policyName\":\"p1\"}]}]"))); + List bucketUsages = client.getUserBucketUsages("mygroup", "u1", "b1"); + Assert.assertEquals(1, bucketUsages.size()); + CloudianUserBucketUsage u1 = bucketUsages.get(0); + Assert.assertEquals("u1", u1.getUserId()); + Assert.assertEquals(1, u1.getBuckets().size()); + CloudianBucketUsage cbu = u1.getBuckets().get(0); + Assert.assertEquals("b1", cbu.getBucketName()); + Assert.assertEquals(5L, cbu.getByteCount().longValue()); + Assert.assertEquals(1L, cbu.getObjectCount().longValue()); + Assert.assertEquals("p1", cbu.getPolicyName()); + } + + @Test + public void getUserBucketUsagesOneUserTwoBuckets() { + CloudianUserBucketUsage expect_u1 = new CloudianUserBucketUsage(); + expect_u1.setUserId("u1"); + CloudianBucketUsage b1 = new CloudianBucketUsage(); + b1.setBucketName("b1"); + b1.setByteCount(123L); + b1.setObjectCount(456L); + b1.setPolicyName("pname"); + CloudianBucketUsage b2 = new CloudianBucketUsage(); + b2.setBucketName("b2"); + b2.setByteCount(789L); + b2.setObjectCount(0L); + b2.setPolicyName("pname2"); + List buckets = new ArrayList(); + buckets.add(b1); + buckets.add(b2); + expect_u1.setBuckets(buckets); + int expect_size = buckets.size(); + + int bucket_count = 0; + StringBuilder sb = new StringBuilder(); + sb.append("[{\"userId\": \"u1\", \"buckets\": ["); + for (CloudianBucketUsage b : buckets) { + sb.append("{\"bucketName\": \""); + sb.append(b.getBucketName()); + sb.append("\", \"byteCount\": "); + sb.append(b.getByteCount()); + sb.append(", \"objectCount\": "); + sb.append(b.getObjectCount()); + sb.append(", \"policyName\": \""); + sb.append(b.getPolicyName()); + sb.append("\"}"); + if (++bucket_count < expect_size) { + sb.append(","); + } + } + sb.append("]}]"); + wireMockRule.stubFor(get(urlEqualTo("/system/bucketusage?groupId=mygroup&userId=u1")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withStatus(200) + .withBody(sb.toString()))); + List bucketUsages = client.getUserBucketUsages("mygroup", "u1", null); + Assert.assertEquals(1, bucketUsages.size()); + CloudianUserBucketUsage u1 = bucketUsages.get(0); + Assert.assertEquals("u1", u1.getUserId()); + Assert.assertEquals(expect_size, u1.getBuckets().size()); + for (int i = 0; i < expect_size; i++) { + CloudianBucketUsage actual = u1.getBuckets().get(i); + CloudianBucketUsage expected = buckets.get(i); + Assert.assertEquals(expected.getBucketName(), actual.getBucketName()); + Assert.assertEquals(expected.getByteCount(), actual.getByteCount()); + Assert.assertEquals(expected.getObjectCount(), actual.getObjectCount()); + Assert.assertEquals(expected.getPolicyName(), actual.getPolicyName()); + } + } + + @Test + public void getUserBucketUsagesTwoUsers() { + CloudianUserBucketUsage expect_u1 = new CloudianUserBucketUsage(); + expect_u1.setUserId("u1"); + CloudianBucketUsage b1 = new CloudianBucketUsage(); + b1.setBucketName("b1"); + b1.setByteCount(123L); + b1.setObjectCount(456L); + b1.setPolicyName("pname"); + CloudianBucketUsage b2 = new CloudianBucketUsage(); + b2.setBucketName("b2"); + b2.setByteCount(789L); + b2.setObjectCount(0L); + b2.setPolicyName("pname2"); + List buckets = new ArrayList(); + buckets.add(b1); + buckets.add(b2); + expect_u1.setBuckets(buckets); + int expect_size = buckets.size(); + + int bucket_count = 0; + StringBuilder sb = new StringBuilder(); + sb.append("[{\"userId\": \"u1\", \"buckets\": ["); + for (CloudianBucketUsage b : buckets) { + sb.append("{\"bucketName\": \""); + sb.append(b.getBucketName()); + sb.append("\", \"byteCount\": "); + sb.append(b.getByteCount()); + sb.append(", \"objectCount\": "); + sb.append(b.getObjectCount()); + sb.append(", \"policyName\": \""); + sb.append(b.getPolicyName()); + sb.append("\"}"); + if (++bucket_count < expect_size) { + sb.append(","); + } + } + sb.append("]}, {\"userId\": \"u2\", \"buckets\": []}]"); + wireMockRule.stubFor(get(urlEqualTo("/system/bucketusage?groupId=mygroup")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withStatus(200) + .withBody(sb.toString()))); + List bucketUsages = client.getUserBucketUsages("mygroup", null, null); + Assert.assertEquals(2, bucketUsages.size()); + CloudianUserBucketUsage u1 = bucketUsages.get(0); + Assert.assertEquals("u1", u1.getUserId()); + Assert.assertEquals(expect_size, u1.getBuckets().size()); + for (int i = 0; i < expect_size; i++) { + CloudianBucketUsage actual = u1.getBuckets().get(i); + CloudianBucketUsage expected = buckets.get(i); + Assert.assertEquals(expected.getBucketName(), actual.getBucketName()); + Assert.assertEquals(expected.getByteCount(), actual.getByteCount()); + Assert.assertEquals(expected.getObjectCount(), actual.getObjectCount()); + Assert.assertEquals(expected.getPolicyName(), actual.getPolicyName()); + } + // 2nd user has 0 buckets + CloudianUserBucketUsage u2 = bucketUsages.get(1); + Assert.assertEquals("u2", u2.getUserId()); + Assert.assertEquals(0, u2.getBuckets().size()); + } + + ///////////////////////////////////////////////////// + //////////////// User API tests ///////////////////// + ///////////////////////////////////////////////////// + + @Test + public void addUserAccount() { + wireMockRule.stubFor(put(urlEqualTo("/user")) + .willReturn(aResponse() + .withStatus(200) + .withBody(""))); + + final CloudianUser user = getTestUser(); + boolean result = client.addUser(user); + Assert.assertTrue(result); + verify(putRequestedFor(urlEqualTo("/user")) + .withRequestBody(containing("userId\":\"" + user.getUserId())) + .withHeader("content-type", equalTo("application/json"))); + } + + @Test + public void addUserAccountFail() { + wireMockRule.stubFor(put(urlEqualTo("/user")) + .willReturn(aResponse() + .withStatus(400) + .withBody(""))); + + final CloudianUser user = getTestUser(); + boolean result = client.addUser(user); + Assert.assertFalse(result); + } + + @Test + public void listUserAccount() { + final String userId = "someUser"; + final String groupId = "someGroup"; + wireMockRule.stubFor(get(urlPathMatching("/user?.*")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withBody("{\"userId\":\"someUser\",\"userType\":\"User\",\"fullName\":\"John Doe (jdoe)\",\"emailAddr\":\"j@doe.com\",\"address1\":null,\"address2\":null,\"city\":null,\"state\":null,\"zip\":null,\"country\":null,\"phone\":null,\"groupId\":\"someGroup\",\"website\":null,\"active\":\"true\",\"canonicalUserId\":\"b3940886468689d375ebf8747b151c37\",\"ldapEnabled\":false}"))); + + final CloudianUser user = client.listUser(userId, groupId); + Assert.assertEquals(user.getActive(), true); + Assert.assertEquals(user.getUserId(), userId); + Assert.assertEquals(user.getGroupId(), groupId); + Assert.assertEquals(user.getUserType(), "User"); + } + + @Test + public void listUserAccountNotFound() { + wireMockRule.stubFor(get(urlPathMatching("/user?.*")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withStatus(204) // 204 not found + .withBody(""))); + + final CloudianUser user = client.listUser("abc", "xyz"); + Assert.assertNull(user); + } + + @Test(expected = ServerApiException.class) + public void listUserAccountFail() { + wireMockRule.stubFor(get(urlPathMatching("/user?.*")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withBody(""))); + + client.listUser("abc", "xyz"); + } + + @Test + public void listUserAccounts() { + final String groupId = "someGroup"; + wireMockRule.stubFor(get(urlPathMatching("/user/list?.*")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withBody("[{\"userId\":\"someUser\",\"userType\":\"User\",\"fullName\":\"John Doe (jdoe)\",\"emailAddr\":\"j@doe.com\",\"address1\":null,\"address2\":null,\"city\":null,\"state\":null,\"zip\":null,\"country\":null,\"phone\":null,\"groupId\":\"someGroup\",\"website\":null,\"active\":\"true\",\"canonicalUserId\":\"b3940886468689d375ebf8747b151c37\",\"ldapEnabled\":false}]"))); + + final List users = client.listUsers(groupId); + Assert.assertEquals(users.size(), 1); + Assert.assertEquals(users.get(0).getActive(), true); + Assert.assertEquals(users.get(0).getGroupId(), groupId); + } + + @Test + public void listUserAccountsEmptyList() { + // empty body with 200 is returned if either: + // 1. the group is unknown (ie. there is no not found case) + // 2. the group contains no users + wireMockRule.stubFor(get(urlPathMatching("/user/list")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withBody(""))); + Assert.assertEquals(0, client.listUsers("someGroup").size()); + } + + @Test(expected = ServerApiException.class) + public void listUserAccountsFail() { + wireMockRule.stubFor(get(urlPathMatching("/user/list?.*")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withStatus(204) // bad protocol response + .withBody(""))); + + client.listUsers("xyz"); + } + + @Test + public void updateUserAccount() { + wireMockRule.stubFor(post(urlEqualTo("/user")) + .willReturn(aResponse() + .withStatus(200) + .withBody(""))); + + final CloudianUser user = getTestUser(); + boolean result = client.updateUser(user); + Assert.assertTrue(result); + verify(postRequestedFor(urlEqualTo("/user")) + .withRequestBody(containing("userId\":\"" + user.getUserId())) + .withHeader("content-type", equalTo("application/json"))); + } + + @Test + public void updateUserAccountFail() { + wireMockRule.stubFor(post(urlEqualTo("/user")) + .willReturn(aResponse() + .withStatus(400) + .withBody(""))); + + boolean result = client.updateUser(getTestUser()); + Assert.assertFalse(result); + } + + @Test + public void removeUserAccount() { + wireMockRule.stubFor(delete(urlPathMatching("/user.*")) + .willReturn(aResponse() + .withStatus(200) + .withBody(""))); + final CloudianUser user = getTestUser(); + boolean result = client.removeUser(user.getUserId(), user.getGroupId()); + Assert.assertTrue(result); + verify(deleteRequestedFor(urlPathMatching("/user.*")) + .withQueryParam("userId", equalTo(user.getUserId()))); + } + + @Test + public void removeUserAccountFail() { + wireMockRule.stubFor(delete(urlPathMatching("/user.*")) + .willReturn(aResponse() + .withStatus(400) + .withBody(""))); + final CloudianUser user = getTestUser(); + boolean result = client.removeUser(user.getUserId(), user.getGroupId()); + Assert.assertFalse(result); + } + + @Test + public void createCredential() { + final String expected_ak = "28d945de2a2623fc9483"; + final String expected_sk = "j2OrPGHF69hp3YsZHRHOCWdAQDabppsBtD7kttr9"; + final long expected_createDate = 1502285593100L; + + final String json = String.format("{\"accessKey\": \"%s\", \"active\": true, \"createDate\": 1502285593100, \"expireDate\": null, \"secretKey\": \"%s\"}", expected_ak, expected_sk); + wireMockRule.stubFor(put(urlPathMatching("/user/credentials.*")) + .willReturn(aResponse() + .withStatus(200) + .withBody(json))); + + CloudianCredential credential = client.createCredential("u1", "g1"); + Assert.assertEquals(expected_ak, credential.getAccessKey()); + Assert.assertEquals(expected_sk, credential.getSecretKey()); + Assert.assertEquals(true, credential.getActive()); + Assert.assertEquals(expected_createDate, credential.getCreateDate().getTime()); + Assert.assertNull(credential.getExpireDate()); + } + + @Test(expected = ServerApiException.class) + public void createCredentialNoSuchUser() { + wireMockRule.stubFor(put(urlPathMatching("/user/credentials.*")) + .willReturn(aResponse() + .withStatus(400) + .withBody(""))); + client.createCredential("u1", "g1"); + } + + @Test(expected = ServerApiException.class) + public void createCredentialMaxCredentials() { + wireMockRule.stubFor(put(urlPathMatching("/user/credentials.*")) + .willReturn(aResponse() + .withStatus(403) + .withBody(""))); + client.createCredential("u1", "g1"); + } + + @Test(expected = ServerApiException.class) + public void createCredentialBadMissingResponse() { + wireMockRule.stubFor(put(urlPathMatching("/user/credentials.*")) + .willReturn(aResponse() + .withStatus(200) + .withBody(""))); // 200 should return a credential + client.createCredential("u1", "g1"); + } + + @Test + public void listCredentials() { + final String expected_ak = "28d945de2a2623fc9483"; + final String expected_sk = "j2OrPGHF69hp3YsZHRHOCWdAQDabppsBtD7kttr9"; + + final String json = String.format("[{\"accessKey\": \"%s\", \"active\": true, \"createDate\": 1502285593100, \"expireDate\": null, \"secretKey\": \"%s\"}]", expected_ak, expected_sk); + wireMockRule.stubFor(get(urlPathMatching("/user/credentials/list.*")) + .willReturn(aResponse() + .withStatus(200) + .withBody(json))); + + List credentials = client.listCredentials("u1", "g1"); + Assert.assertEquals(1, credentials.size()); + Assert.assertEquals(expected_ak, credentials.get(0).getAccessKey()); + } + + @Test + public void listCredentialsMany() { + final String expected_ak = "28d945de2a2623fc9483"; + final String expected_sk = "j2OrPGHF69hp3YsZHRHOCWdAQDabppsBtD7kttr9"; + final int expected_size = 3; + StringBuilder sb = new StringBuilder(); + sb.append("["); + for (int i = 0; i < expected_size; i++) { + sb.append(String.format("{\"accessKey\": \"%s-%d\", \"active\": true, \"createDate\": 1502285593100, \"expireDate\": null, \"secretKey\": \"%s-%d\"}", expected_ak, i, expected_sk, i)); + if (i + 1 < expected_size) { + sb.append(","); + } + } + sb.append("]"); + String json = sb.toString(); + wireMockRule.stubFor(get(urlPathMatching("/user/credentials/list.*")) + .willReturn(aResponse() + .withStatus(200) + .withBody(json))); + + List credentials = client.listCredentials("u1", "g1"); + Assert.assertEquals(expected_size, credentials.size()); + Assert.assertEquals(expected_ak + "-2", credentials.get(2).getAccessKey()); + } + + @Test + public void listCredentialsEmptyList() { + wireMockRule.stubFor(get(urlPathMatching("/user/credentials/list.*")) + .willReturn(aResponse() + .withStatus(204) // 204 is empty list for credentials + .withBody(""))); + + List credentials = client.listCredentials("u1", "g1"); + Assert.assertEquals(0, credentials.size()); + } + + @Test(expected = ServerApiException.class) + public void listCredentialsNoSuchUser() { + wireMockRule.stubFor(get(urlPathMatching("/user/credentials/list.*")) + .willReturn(aResponse() + .withStatus(400) // No such user case + .withBody(""))); + + client.listCredentials("u1", "g1"); + } + + @Test(expected = ServerApiException.class) + public void listCredentialsBad200EmptyBody() { + wireMockRule.stubFor(get(urlPathMatching("/user/credentials/list.*")) + .willReturn(aResponse() + .withStatus(200) // Bad protocol. should be 204 if empty + .withBody(""))); + + client.listCredentials("u1", "g1"); + } + + ////////////////////////////////////////////////////// + //////////////// Group API tests ///////////////////// + ////////////////////////////////////////////////////// + + @Test + public void addGroup() { + wireMockRule.stubFor(put(urlEqualTo("/group")) + .willReturn(aResponse() + .withStatus(200) + .withBody(""))); + + final CloudianGroup group = getTestGroup(); + boolean result = client.addGroup(group); + Assert.assertTrue(result); + verify(putRequestedFor(urlEqualTo("/group")) + .withRequestBody(containing("groupId\":\"someGroupId")) + .withHeader("content-type", equalTo("application/json"))); + } + + @Test + public void addGroupFail() throws Exception { + wireMockRule.stubFor(put(urlEqualTo("/group")) + .willReturn(aResponse() + .withStatus(400) + .withBody(""))); + + final CloudianGroup group = getTestGroup(); + boolean result = client.addGroup(group); + Assert.assertFalse(result); + } + + @Test + public void listGroup() { + final String groupId = "someGroup"; + wireMockRule.stubFor(get(urlPathMatching("/group.*")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withBody("{\"groupId\":\"someGroup\",\"groupName\":\"/someDomain\",\"ldapGroup\":null,\"active\":\"true\",\"ldapEnabled\":false,\"ldapServerURL\":null,\"ldapUserDNTemplate\":null,\"ldapSearch\":null,\"ldapSearchUserBase\":null,\"ldapMatchAttribute\":null}"))); + + final CloudianGroup group = client.listGroup(groupId); + Assert.assertEquals(group.getActive(), true); + Assert.assertEquals(group.getGroupId(), groupId); + } + + @Test + public void listGroupNotFound() { + wireMockRule.stubFor(get(urlPathMatching("/group.*")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withStatus(204) // group not found + .withBody(""))); + Assert.assertNull(client.listGroup("someGroup")); + } + + @Test(expected = ServerApiException.class) + public void listGroupFail() { + // Returning 200 with an empty body is not expected behaviour + wireMockRule.stubFor(get(urlPathMatching("/group.*")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withBody(""))); + + client.listGroup("xyz"); + } + + @Test + public void listGroups() { + final String groupId = "someGroup"; + wireMockRule.stubFor(get(urlEqualTo("/group/list")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withBody("[{\"groupId\":\"someGroup\",\"groupName\":\"/someDomain\",\"ldapGroup\":null,\"active\":\"true\",\"ldapEnabled\":false,\"ldapServerURL\":null,\"ldapUserDNTemplate\":null,\"ldapSearch\":null,\"ldapSearchUserBase\":null,\"ldapMatchAttribute\":null}]"))); + + final List groups = client.listGroups(); + Assert.assertEquals(groups.size(), 1); + Assert.assertEquals(groups.get(0).getActive(), true); + Assert.assertEquals(groups.get(0).getGroupId(), groupId); + } + + @Test + public void listGroupsEmptyList() { + wireMockRule.stubFor(get(urlEqualTo("/group/list")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withBody(""))); + + final List groups = client.listGroups(); + Assert.assertEquals(0, groups.size()); + } + + @Test(expected = ServerApiException.class) + public void listGroupsBad204Response() { + wireMockRule.stubFor(get(urlEqualTo("/group/list")) + .willReturn(aResponse() + .withHeader("content-type", "application/json") + .withStatus(204) // bad response. should never be 204 + .withBody(""))); + client.listGroups(); + } + + @Test + public void updateGroup() { + wireMockRule.stubFor(post(urlEqualTo("/group")) + .willReturn(aResponse() + .withStatus(200) + .withBody(""))); + + final CloudianGroup group = getTestGroup(); + boolean result = client.updateGroup(group); + Assert.assertTrue(result); + verify(postRequestedFor(urlEqualTo("/group")) + .withRequestBody(containing("groupId\":\"" + group.getGroupId())) + .withHeader("content-type", equalTo("application/json"))); + } + + @Test + public void updateGroupFail() { + wireMockRule.stubFor(post(urlEqualTo("/group")) + .willReturn(aResponse() + .withStatus(400) + .withBody(""))); + + boolean result = client.updateGroup(getTestGroup()); + Assert.assertFalse(result); + } + + @Test + public void removeGroup() { + wireMockRule.stubFor(delete(urlPathMatching("/group.*")) + .willReturn(aResponse() + .withStatus(200) + .withBody(""))); + final CloudianGroup group = getTestGroup(); + boolean result = client.removeGroup(group.getGroupId()); + Assert.assertTrue(result); + verify(deleteRequestedFor(urlPathMatching("/group.*")) + .withQueryParam("groupId", equalTo(group.getGroupId()))); + } + + @Test + public void removeGroupFail() { + wireMockRule.stubFor(delete(urlPathMatching("/group.*")) + .willReturn(aResponse() + .withStatus(400) + .withBody(""))); + final CloudianGroup group = getTestGroup(); + boolean result = client.removeGroup(group.getGroupId()); + Assert.assertFalse(result); + } +} diff --git a/plugins/pom.xml b/plugins/pom.xml index 1667e151cfc5..db228881a915 100755 --- a/plugins/pom.xml +++ b/plugins/pom.xml @@ -139,6 +139,7 @@ storage/volume/primera storage/object/minio storage/object/ceph + storage/object/cloudian storage/object/simulator diff --git a/plugins/storage/object/cloudian/README.md b/plugins/storage/object/cloudian/README.md new file mode 100644 index 000000000000..a0f68d7bc12c --- /dev/null +++ b/plugins/storage/object/cloudian/README.md @@ -0,0 +1,175 @@ +# Cloudian HyperStore Object Storage Plugin + +## Plugin Purpose + +This plugin implements the Object Storage DataStore for Cloudian HyperStore. + +## About Cloudian HyperStore + +Cloudian HyperStore is a fully AWS-S3 compatible Object Storage solution. The following services are used by this plugin. + +| Service | HTTP Port | HTTPS Port | Description | +|:-------:|----------:|-----------:|:-----------------------| +| Admin | | 19443 | User Management etc. | +| S3 | 80 | 443 | AWS-S3 compatible API | +| IAM | 16080 | 16443 | AWS-IAM compatible API | + +## Configuration + +### HyperStore Configuration + +1. Enable Bucket Usage Statistics + + Bucket Level QoS settings must be set to true. On HyperStore 8+, this can be done as follows. Earlier versions require puppet configuration which is not documented here. + + ```shell + hsh$ hsctl config set s3.qos.bucketLevel=true + hsh$ hsctl config apply s3 cmc + hsh$ hsctl service restart s3 cmc --nodes=ALL + ``` + +2. The Admin API Username and Password + + The connector requires an ADMIN API username and password to connect to the Admin service and create and manage HyperStore resources such as HyperStore Users and Groups. Please review your HyperStore Admin Guide and the settings under the `admin.auth` namespace. + +3. Enable Object Lock via License + + HyperStore fully supports S3 Object Lock. However, Object Lock is currently only available with a special Object Lock License from Cloudian. If the connected HyperStore system does not have an Object Lock license, it will only allow creating regular buckets. Contact Cloudian Support to request an Object Lock license if required. + +### CloudStack Configuration + +A new `Cloudian HyperStore` Object Store can be added by the CloudStack `admin` user via the UI -> Infrastructure -> Object Storage -> Add Object Storage button. + +Once added, this passes various configuration parameters to the LifeCycle class as a map with the following keys and values. + +```text +DataStoreInfo MAP +++++++++++++++++++++++++++++++++++++++ +| Key | Value | +|-------------|----------------------| +|name | | +|providerName | Cloudian HyperStore | +|url | | +|details | ===========|=====+ +++++++++++++++++++++++++++++++++++++++ v + v +  +======================================+ + V +Details MAP +++++++++++++++++++++++++++++++++++ +| Key | Value | +|-------------|------------------| +| validateSSL | true/false | +| accesskey | Admin Username | +| secretkey | Admin Password | +| s3Url | S3 endpoint URL | +| iamUrl | IAM endpoint URL | +++++++++++++++++++++++++++++++++++ +``` + +The following "details" map entries are all required. + +- validateSSL : The ADMIN API is internal and may not have a proper SSL Certificate. +- accesskey : Reuse of a shared configuration parameter to pass the Admin Username. +- secretkey : Reuse of a shared configuration parameter to pass the Admin password. +- s3Url : The HyperStore S3 endpoint URL. HTTPS is preferred when the service has a proper SSL Certificate which should be true in production. +- iamUrl : The HyperStore IAM endpoint URL. Again HTTPS is preferred. + +The LifeCycle initialize() method should validate connectivity to the different services. + +## CloudStack Account Mappings + +| CloudStack | HyperStore | Name Assigned | +|:-----------|:-----------------|:---------------------| +| Domain | HyperStore Group | Domain UUID | +| Account | HyperStore User | Account UUID | +| Project | HyperStore User | Project Account UUID | + +When a CloudStack Account user creates a bucket under their account for the first time a new HyperStore User is allocated under the HyperStore Group that is mapped to the CloudStack Domain. A new HyperStore Group is also allocated if one does not already exist. + +## HyperStore User Resources + +The following additional resources are also created for each HyperStore User. + +| Resource | Description | +|--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Root Credential Pair | These credentials have full access to the HyperStore User account. They are used to manage the IAM user resources listed below as well as to perform any top level bucket actions such as creating buckets, updating policies, enabling versioning etc. | +| IAM User "CloudStack" | The "CloudStack" IAM user is created with an inline policy as-per below. The IAM user is used by the CloudStack Bucket Browser UI to manage bucket contents. | +| IAM User Policy | This inline IAM user policy grants the "CloudStack" IAM user permission to any S3 action except `s3:createBucket` and `s3:deleteBucket`. This is mostly to ensure that all Buckets remain under CloudStack control as well as to restrict control over IAM actions. | +| IAM User Credential Pair | The "CloudStack" IAM user credentials are also managed by the plugin and are made available to the user under the "Bucket Details" page. They are additionally used by the CloudStack Bucket Browser UI. They are restricted by the aforementioned user policy. | + +## Bucket Management + +The following are noteworthy. + +### Bucket Quota is Unsupported + +Cloudian HyperStore does not currently support restricting the size of a bucket to a particular quota limit. The plugin accepts a quota value of 0 to indicate no quota setting. When creating a bucket in the CloudStack UI, the user is required to set a quota of 0. Any other value will fail. + +### Bucket Usage + +HyperStore does not collect bucket usage statistics by default. They must be enabled by the HyperStore Administrator. On systems where this has not been enabled, bucket usage is reported as 0 bytes. + +See the configuration section above for more details. + +### Supported Bucket Policies + +Two "policies" are configurable using the CloudStack interface. + +- Private : Objects are only accessible to the bucket owner. This is the equivalent of no bucket policy (and is implemented that way). +- Public : Objects are readable to everyone. Listing of all bucket objects is not granted so the object name must be known in order to access it. + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PublicReadForObjects", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::BUCKET/*" + } + ] + } + ``` + +### Additional Bucket CORS Settings + +Buckets created by the CloudStack plugin are additionally created with a Cross-Origin Resource Sharing (CORS) configuration. A permissive CORS setting on buckets is required by the CloudStack Bucket Browser UI functionality as it is written in JavaScript and runs in the end user's browser. + +```xml + + + AllowAny + * + GET + HEAD + PUT + POST + DELETE + * + + +``` + +### Visibility of other Buckets under the same HyperStore User + +While the "CloudStack" IAM user cannot create other buckets under the HyperStore User account, there are other reasons that buckets can exist under the HyperStore user but not be known by the CloudStack. These include network connectivity issues between creating a bucket and updating the database. Note that this can usually be rectified by retrying the create bucket operation. + +While a bucket is not visible to CloudStack, a 3rd party application using the same IAM credentials will be able to see and operate on the bucket. + +## Interoperability with Existing HyperStore Plugin + +This plugin is mostly interoperable with the existing HyperStore Infrastructure plugin. However, it is recommended to use one or the other but __not both__ plugins. + +The purpose of the older HyperStore infrastructure plugin is to grant full access to the HyperStore User that is mapped to the CloudStack Account. As such it grants the logged in CloudStack Account Single-Sign-On (SSO) into the Cloudian Management Console (CMC) as the Root User of the HyperStore User. This would allow the CloudStack Account to create and delete HyperStore User resources (credentials/IAM users/federated logins/buckets/etc) outside CloudStack control. + +In comparison, this plugin attempts to restrict HyperStore User level, IAM and Bucket level actions by providing CloudStack Account access via IAM credentials. + +## Known Issues + +1. Currently, there is no way to edit the Object Storage Configuration for any of the parameters configured in the "details" map. It seems that other Object Storage providers have the same issue. +2. The Bucket Browser UI feature may not work correctly on HyperStore versions older than 8.2 due to some bugs in the CORS implementation. However, everything else will still function correctly. +3. Object metadata is not correctly displayed in the CloudStack Bucket Browser. This is due to the javascript client using a MinIO only (non-s3 compatible) extension call that collects the metadata as part of the bucket listing. To fix this for non-MinIO S3 Object Stores, Object Metadata should be collected using the S3 standard headObject operation. +4. CloudStack does not yet have a deleteUser API for Object Stores so when a CloudStack Account is deleted, the mapped HyperStore User is not currently cleaned up. diff --git a/plugins/storage/object/cloudian/pom.xml b/plugins/storage/object/cloudian/pom.xml new file mode 100644 index 000000000000..3d9a0c7ee8cd --- /dev/null +++ b/plugins/storage/object/cloudian/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + cloud-plugin-storage-object-cloudian + Apache CloudStack Plugin - Cloudian HyperStore object storage provider + + org.apache.cloudstack + cloudstack-plugins + 4.21.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} + + + org.apache.cloudstack + cloud-plugin-integrations-cloudian-connector + ${project.version} + + + com.amazonaws + aws-java-sdk-core + + + com.amazonaws + aws-java-sdk-iam + + + com.amazonaws + aws-java-sdk-s3 + + + com.github.tomakehurst + wiremock-standalone + ${cs.wiremock.version} + test + + + diff --git a/plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudianHyperStoreObjectStoreDriverImpl.java b/plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudianHyperStoreObjectStoreDriverImpl.java new file mode 100644 index 000000000000..a0a717f52e42 --- /dev/null +++ b/plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudianHyperStoreObjectStoreDriverImpl.java @@ -0,0 +1,890 @@ +/* + * 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. + */ +// SPDX-License-Identifier: Apache-2.0 +package org.apache.cloudstack.storage.datastore.driver; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; + +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.cloudian.client.CloudianClient; +import org.apache.cloudstack.cloudian.client.CloudianCredential; +import org.apache.cloudstack.cloudian.client.CloudianGroup; +import org.apache.cloudstack.cloudian.client.CloudianUser; +import org.apache.cloudstack.cloudian.client.CloudianUserBucketUsage; +import org.apache.cloudstack.cloudian.client.CloudianUserBucketUsage.CloudianBucketUsage; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; +import org.apache.cloudstack.storage.datastore.db.ObjectStoreDao; +import org.apache.cloudstack.storage.datastore.db.ObjectStoreDetailsDao; +import org.apache.cloudstack.storage.datastore.db.ObjectStoreVO; +import org.apache.cloudstack.storage.datastore.util.CloudianHyperStoreUtil; +import org.apache.cloudstack.storage.object.BaseObjectStoreDriverImpl; +import org.apache.cloudstack.storage.object.Bucket; +import org.apache.cloudstack.storage.object.BucketObject; + +import com.amazonaws.AmazonClientException; +import com.amazonaws.services.identitymanagement.AmazonIdentityManagement; +import com.amazonaws.services.identitymanagement.model.AccessKey; +import com.amazonaws.services.identitymanagement.model.AccessKeyMetadata; +import com.amazonaws.services.identitymanagement.model.CreateAccessKeyRequest; +import com.amazonaws.services.identitymanagement.model.CreateUserRequest; +import com.amazonaws.services.identitymanagement.model.DeleteAccessKeyRequest; +import com.amazonaws.services.identitymanagement.model.EntityAlreadyExistsException; +import com.amazonaws.services.identitymanagement.model.ListAccessKeysRequest; +import com.amazonaws.services.identitymanagement.model.ListAccessKeysResult; +import com.amazonaws.services.identitymanagement.model.NoSuchEntityException; +import com.amazonaws.services.identitymanagement.model.PutUserPolicyRequest; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.AccessControlList; +import com.amazonaws.services.s3.model.BucketCrossOriginConfiguration; +import com.amazonaws.services.s3.model.BucketPolicy; +import com.amazonaws.services.s3.model.BucketVersioningConfiguration; +import com.amazonaws.services.s3.model.CORSRule; +import com.amazonaws.services.s3.model.CreateBucketRequest; +import com.amazonaws.services.s3.model.SSEAlgorithm; +import com.amazonaws.services.s3.model.ServerSideEncryptionByDefault; +import com.amazonaws.services.s3.model.ServerSideEncryptionConfiguration; +import com.amazonaws.services.s3.model.ServerSideEncryptionRule; +import com.amazonaws.services.s3.model.SetBucketCrossOriginConfigurationRequest; +import com.amazonaws.services.s3.model.SetBucketEncryptionRequest; +import com.amazonaws.services.s3.model.SetBucketVersioningConfigurationRequest; +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.domain.Domain; +import com.cloud.domain.dao.DomainDao; +import com.cloud.user.Account; +import com.cloud.user.AccountDetailsDao; +import com.cloud.user.dao.AccountDao; +import com.cloud.utils.exception.CloudRuntimeException; + +public class CloudianHyperStoreObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { + @Inject + AccountDao _accountDao; + + @Inject + AccountDetailsDao _accountDetailsDao; + + @Inject + DomainDao _domainDao; + + @Inject + ObjectStoreDao _storeDao; + + @Inject + BucketDao _bucketDao; + + @Inject + ObjectStoreDetailsDao _storeDetailsDao; + + @Override + public DataStoreTO getStoreTO(DataStore store) { + return null; + } + + /** + * Get the HyperStore user id for the current account. + * @param account the current account + * @return the userId based on the CloudStack account uuid. + */ + protected String getHyperStoreUserId(Account account) { + return account.getUuid(); + } + + /** + * Get the HyperStore tenant/group id for the current domain. + * @param domain the current domain + * @return the groupId based on the CloudStack domain uuid + */ + protected String getHyperStoreGroupId(Domain domain) { + return domain.getUuid(); + } + + /** + * Create the HyperStore user resources matching this account if it doesn't exist. + * + * The following resources are created for the account: + * - HyperStore Group to match the CloudStack Domain UUID + * - HyperStore User to match the CloudStack Account UUID + * - HyperStore Root User Credentials to manage Account Buckets etc (kept private to this plugin) + * - HyperStore IAM User with IAM policy granting all S3 actions except create/delete buckets. + * - HyperStore IAM User Credentials (visible to end user as part of Bucket Details) + * + * @param accountId the CloudStack account + * @param storeId the object store. + * + * @return true if user exists or was created, false if there was some issue creating it. + * @throws CloudRuntimeException on errors checking if the user exists or if the HyperStore user or group is disabled. + */ + @Override + public boolean createUser(long accountId, long storeId) { + Account account = _accountDao.findById(accountId); + Domain domain = _domainDao.findById(account.getDomainId()); + String hsUserId = getHyperStoreUserId(account); + String hsGroupId = getHyperStoreGroupId(domain); + + CloudianClient client = getCloudianClientByStoreId(storeId); + logger.debug("Checking if user id={} group id={} exists.", hsGroupId, hsUserId); + CloudianUser user = client.listUser(hsUserId, hsGroupId); + if (user == null) { + // Create the group if it doesn't already exist + createHSGroup(client, hsGroupId, domain); + // Create the user under the group. + user = createHSUser(client, hsUserId, hsGroupId, account); + if (user == null) { + return false; // already logged. + } + } else if (! user.getActive()) { + // Normally this would be true unless an administrator has explicitly disabled the user account. + String msg = String.format("The User id=%s group id=%s is Disabled. Consult your HyperStore Administrator.", hsUserId, hsGroupId); + logger.error(msg); + throw new CloudRuntimeException(msg); + } else { + // User exists and is active. We know that the group therefore exists but + // we should ensure that it is active or it will lead to unknown access key errors + // which might confuse the administrator. Checking is clearer. + CloudianGroup group = client.listGroup(hsGroupId); + if (group != null && ! group.getActive()) { + String msg = String.format("The group id=%s is Disabled. Consult your HyperStore Administrator.", hsGroupId); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + } + + // We either created a new account or found an existing one. + CloudianCredential credential = createHSCredential(client, hsUserId, hsGroupId); + + // Next, ensure we the IAM User Credentials exist. These are available + // to the user as part of the bucket details instead of the Root credentials. + Map details = _accountDetailsDao.findDetails(accountId); + AccessKey iamCredential = createIAMCredentials(storeId, details, credential); + + // persist the root and iam credentials in the database and update all bucket details. + persistCredentials(storeId, accountId, details, credential, iamCredential); + return true; + } + + /** + * Create IAM credentials if required. + * + * When the HyperStore user is first created, this method will create an IAM User with an appropriate + * permission policy and a set of credentials which will be returned. + * After the first run, the IAM resources should already be in place in which case we just ensure + * the credentials we are using are still available and if not, it tries to recreate the IAM resources. + * + * @param storeId the store + * @param details a map of existing account details that we know about including any saved IAM credentials. + * @param credential the account Root User credentials (to manage the IAM resources). + * @return an AccessKey object for newly created IAM credentials or null if existing credentials were ok + * and nothing was created. + */ + protected AccessKey createIAMCredentials(long storeId, Map details, CloudianCredential credential) { + AmazonIdentityManagement iamClient = getIAMClientByStoreId(storeId, credential); + final String iamUser = CloudianHyperStoreUtil.IAM_USER_USERNAME; + + // If an accessKeyId is known to us, check IAM still has it. + String iamAccessKeyId = details.get(CloudianHyperStoreUtil.KEY_IAM_ACCESS_KEY); + if (iamAccessKeyId != null) { + try { + logger.debug("Looking for IAM credential {} for IAM User {}", iamAccessKeyId, iamUser); + ListAccessKeysResult listAccessKeyResult = iamClient.listAccessKeys(new ListAccessKeysRequest().withUserName(iamUser)); + for (AccessKeyMetadata accessKeyMetadata : listAccessKeyResult.getAccessKeyMetadata()) { + if (iamAccessKeyId.equals(accessKeyMetadata.getAccessKeyId())) { + return null; // The IAM AccessKeyId still exists (as expected). return null. + } + // Usually, there will only be 1 credential that we manage, but an error persisting + // credentials might leave an un-managed credential which we can just delete. It is better + // to delete as otherwise, we may hit a max credential limit for this IAM user. + deleteIAMCredential(iamClient, iamUser, accessKeyMetadata.getAccessKeyId()); + } + } catch (NoSuchEntityException e) { + // No IAM User. Ignore and fix this below. + } + } + + // If we get here, a usable credential does not yet exist so create it. + // Before creating it, we also need to ensure the IAM User that will own it exists. + boolean createdUser = false; + try { + iamClient.createUser(new CreateUserRequest(iamUser)); + logger.info("Created IAM user {} for account", iamUser); + createdUser = true; + } catch (EntityAlreadyExistsException e) { + // User already exists. Ignore and continue. + } + + // Always Add or Update the IAM policy + iamClient.putUserPolicy(new PutUserPolicyRequest(iamUser, CloudianHyperStoreUtil.IAM_USER_POLICY_NAME, CloudianHyperStoreUtil.IAM_USER_POLICY)); + + if (! createdUser && iamAccessKeyId == null) { + // User already exists but we never saved any access key before. We should try clean up + logger.debug("Looking for any un-managed IAM credentials for IAM User {}", iamUser); + ListAccessKeysResult listRes = iamClient.listAccessKeys(new ListAccessKeysRequest().withUserName(iamUser)); + for (AccessKeyMetadata accessKeyMetadata : listRes.getAccessKeyMetadata()) { + deleteIAMCredential(iamClient, iamUser, accessKeyMetadata.getAccessKeyId()); + } + } + + // Create and return the new IAM credentials for this user. + AccessKey iamAccessKey = iamClient.createAccessKey(new CreateAccessKeyRequest(iamUser)).getAccessKey(); + logger.info("Created IAM Credential {} for IAM User {}", iamAccessKey.getAccessKeyId(), iamUser); + return iamAccessKey; + } + + /** + * Delete an IAM Credential. + * + * @param iamClient a valid iam connection + * @param iamUser the IAM user that owns the credential to delete. + * @param accessKeyId The IAM credential to delete + */ + protected void deleteIAMCredential(AmazonIdentityManagement iamClient, String iamUser, String accessKeyId) { + DeleteAccessKeyRequest deleteAccessKeyRequest = new DeleteAccessKeyRequest(); + deleteAccessKeyRequest.setUserName(iamUser); + deleteAccessKeyRequest.setAccessKeyId(accessKeyId); + logger.info("Deleting un-managed IAM AccessKeyId {} for IAM User {}", accessKeyId, iamUser); + iamClient.deleteAccessKey(deleteAccessKeyRequest); + } + + /** + * Persist the Root and IAM user credentials with the Account as required. + * @param storeId the store + * @param accountId the CloudStack account the credential belongs to + * @param details the Account details map containing any pre-existing credential entries + * @param credential the HyperStore credential assigned to this account. + * @param iamCredential the new IAM credential or null if nothing new to persist. + */ + private void persistCredentials(long storeId, long accountId, Map details, CloudianCredential credential, AccessKey iamCredential) { + boolean persist = false; + + String rootAccessKey = details.get(CloudianHyperStoreUtil.KEY_ROOT_ACCESS_KEY); + if (! credential.getAccessKey().equals(rootAccessKey)) { + // Persist the new (possibly rotated) credential pair + details.put(CloudianHyperStoreUtil.KEY_ROOT_ACCESS_KEY, credential.getAccessKey()); + details.put(CloudianHyperStoreUtil.KEY_ROOT_SECRET_KEY, credential.getSecretKey()); + persist = true; + } + + if (iamCredential != null) { + // Persist the new IAM credentials + details.put(CloudianHyperStoreUtil.KEY_IAM_ACCESS_KEY, iamCredential.getAccessKeyId()); + details.put(CloudianHyperStoreUtil.KEY_IAM_SECRET_KEY, iamCredential.getSecretAccessKey()); + updateAccountBucketCredentials(storeId, accountId, iamCredential); + persist = true; + } + + if (persist) { + logger.debug("Persisting new credential information for accountId={}", accountId); + _accountDetailsDao.persist(accountId, details); + } + } + + /** + * Update bucket details associated with this store/account to use the new IAM credentials. + * + * @param storeId the store + * @param accountId the user account + * @param iamCredential the IAM credentials to associate with any existing buckets. + */ + private void updateAccountBucketCredentials(long storeId, long accountId, AccessKey iamCredential) { + List bucketList = _bucketDao.listByObjectStoreIdAndAccountId(storeId, accountId); + for (BucketVO bucketVO : bucketList) { + logger.info("Updating accountId={} bucket {} with new IAM credentials", accountId, bucketVO.getName()); + bucketVO.setAccessKey(iamCredential.getAccessKeyId()); + bucketVO.setSecretKey(iamCredential.getSecretAccessKey()); + _bucketDao.update(bucketVO.getId(), bucketVO); + } + } + + /** + * Create a HyperStore credential for the user if one does not already exist. + * @param client ADMIN API connection + * @param hsUserId HyperStore userId + * @param hsGroupId HyperStore groupId + * + * @return a Root Credential (never null) + * @throws ServerApiException if any error is encountered + */ + protected CloudianCredential createHSCredential(CloudianClient client, String hsUserId, String hsGroupId) { + // find the oldest active Root credential in the account. + List credentials = client.listCredentials(hsUserId, hsGroupId); + CloudianCredential credential = null; + for (CloudianCredential candidate : credentials) { + if (! candidate.getActive()) { + continue; + } + if (credential == null || credential.isNewerThan(candidate)) { + credential = candidate; + } + } + + if (credential == null) { + // nothing found, create one + logger.debug("No active credentials found for groupId={} userId={}. Creating one.", hsGroupId, hsUserId); + credential = client.createCredential(hsUserId, hsGroupId); + logger.info("Created Root credentials for groupId={} userId={}.", hsGroupId, hsUserId); + } + + // Either found or successfully created a credential. + return credential; + } + + /** + * Create the HyperStore Group if it does not already exist. + * @param client a CloudianClient connection + * @param hsGroupId the name of the HyperStore group to create. + * @param domain the domain that is being mapped to the HyperStore group. + * @throws CloudRuntimeException if the group cannot be created or the group exists but is disabled. + */ + private void createHSGroup(CloudianClient client, String hsGroupId, Domain domain) { + // The group will usually exist so lets look for it before trying to add it. + logger.debug("Checking if group {} exists.", hsGroupId); + CloudianGroup group = client.listGroup(hsGroupId); + if (group == null) { + group = new CloudianGroup(); + group.setGroupId(hsGroupId); + group.setActive(Boolean.TRUE); + group.setGroupName(domain.getPath()); + client.addGroup(group); + logger.info("Created group {} for domain {} successfully.", hsGroupId, domain.getPath()); + return; + } + + // Group exists. Confirm that it is usable. + if (! group.getActive()) { + String msg = String.format("The group %s is Disabled. Consult your HyperStore Administrator.", hsGroupId); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + + // Group exists and is enabled. Nothing to log. + return; + } + + /** + * Create a new HyperStore user + * + * @param client admin api client + * @param hsUserId the user to create + * @param hsGroupId the group to add him to + * @param account the account the user represents + * @return user object if successfully created, null otherwise + * @throws ServerAPIException if on other other. + */ + private CloudianUser createHSUser(CloudianClient client, String hsUserId, String hsGroupId, Account account) { + CloudianUser user = new CloudianUser(); + user.setActive(Boolean.TRUE); + user.setGroupId(hsGroupId); + user.setUserId(hsUserId); + user.setUserType(CloudianUser.USER); + user.setFullName(account.getAccountName()); + + if (! client.addUser(user)) { + // The failure shouldn't be that the user already exists at this point so its something else. + logger.error("Failed to add user id={} groupId={}", hsUserId, hsGroupId); + return null; + } else { + logger.info("Created new user id={} groupId={}", hsUserId, hsGroupId); + return user; + } + + } + + /** + * Create a bucket in HyperStore under the Account listed in the bucket argument. + * + * @param bucket the bucket to create. + * @param objectLock set to true to enable ObjectLock (requires an ObjectLock license), false for a normal bucket. + * + * @throws CloudRuntimeException if ObjectLock was requested but the feature is disabled due to license or any + * other failure. + */ + @Override + public Bucket createBucket(Bucket bucket, boolean objectLock) { + String bucketName = bucket.getName(); + long storeId = bucket.getObjectStoreId(); + long accountId = bucket.getAccountId(); + + // get an s3client using Account Root User Credentials + Map storeDetails = _storeDetailsDao.getDetails(storeId); + String s3url = storeDetails.get(CloudianHyperStoreUtil.STORE_DETAILS_KEY_S3_URL); + Map accountDetails = _accountDetailsDao.findDetails(accountId); + String accessKey = accountDetails.get(CloudianHyperStoreUtil.KEY_ROOT_ACCESS_KEY); + String secretKey = accountDetails.get(CloudianHyperStoreUtil.KEY_ROOT_SECRET_KEY); + String iamAccessKey = accountDetails.get(CloudianHyperStoreUtil.KEY_IAM_ACCESS_KEY); + String iamSecretKey = accountDetails.get(CloudianHyperStoreUtil.KEY_IAM_SECRET_KEY); + AmazonS3 s3client = getS3Client(s3url, accessKey, secretKey); + + // Step 1: Create the bucket + try { + // Create the bucket with ObjectLock if requested + logger.info("Creating bucket {}", bucketName); + CreateBucketRequest cbRequest = new CreateBucketRequest(bucketName); + cbRequest.setObjectLockEnabledForBucket(objectLock); + s3client.createBucket(cbRequest); + } catch (AmazonClientException e) { + logger.error("Create bucket failed", e); + throw new CloudRuntimeException(e); + } + + // Step 2: Any Exception here, we try to delete the bucket. + // If deletion fails, it is not the end of the world as the + // user can try again to create the bucket which if he is + // already the owner, it will succeed. + try { + // Enable a permissive CORS configuration + configureBucketCORS(s3client, bucketName); + + // Update the Bucket Information (for Bucket details page etc) + BucketVO bucketVO = _bucketDao.findById(bucket.getId()); + bucketVO.setAccessKey(iamAccessKey); + bucketVO.setSecretKey(iamSecretKey); + bucketVO.setBucketURL(s3url + "/" + bucketName); + _bucketDao.update(bucket.getId(), bucketVO); + return bucketVO; + } catch (Exception e) { + // Error with DB or CORS. Delete the bucket from S3 + logger.error("There was a failure after bucket creation. Trying to clean up", e); + try { + s3client.deleteBucket(bucketName); + logger.info("cleanup succeeded."); + } catch (AmazonClientException e1) { + logger.error("Cleanup for create bucket also failed with", e); + } + throw new CloudRuntimeException(e); + } + } + + /** + * Configure a permissive CrossOrigin setting on the given bucket. + * + * Cloudian does not enable CORS by default. The CORS configuration + * is required by CloudStack so that the Javascript S3 bucket + * browser can function properly. + * + * This method does not catch any exceptions which should be caught + * by the calling method. + * + * @param s3client bucket owner s3client + * @param bucketName the bucket name. + * + * @throws AmazonClientException and derivatives + */ + private void configureBucketCORS(AmazonS3 s3client, String bucketName) { + logger.debug("Configuring CORS for bucket {}", bucketName); + + List corsRules = new ArrayList(); + CORSRule allowAnyRule = new CORSRule().withId("AllowAny"); + allowAnyRule.setAllowedOrigins("*"); + allowAnyRule.setAllowedHeaders("*"); + allowAnyRule.setAllowedMethods( + CORSRule.AllowedMethods.HEAD, + CORSRule.AllowedMethods.GET, + CORSRule.AllowedMethods.PUT, + CORSRule.AllowedMethods.POST, + CORSRule.AllowedMethods.DELETE); + corsRules.add(allowAnyRule); + BucketCrossOriginConfiguration corsConfig = new BucketCrossOriginConfiguration(); + corsConfig.setRules(corsRules); + SetBucketCrossOriginConfigurationRequest corsRequest = new SetBucketCrossOriginConfigurationRequest(bucketName, corsConfig); + s3client.setBucketCrossOriginConfiguration(corsRequest); + logger.info("Successfully configured CORS for bucket {}", bucketName); + } + + /** + * This API seems to be called by the StorageManagementImpl to validate that the + * main Object Store URL (in our case the Admin API endpoint) is correct. As + * such, let's return all buckets owned by accounts managed by this object + * store using the same API as the bucket usage as that uses the ADMIN API. + * + * @return a list of Bucket objects where only the bucketName is set. + */ + @Override + public List listBuckets(long storeId) { + Map bucketUsage = getAllBucketsUsage(storeId); + List bucketList = new ArrayList(); + for (String bucketName : bucketUsage.keySet()) { + Bucket bucket = new BucketObject(); + bucket.setName(bucketName); + bucketList.add(bucket); + } + return bucketList; + } + + /** + * Delete an empty bucket. + * This operation fails if the bucket is not empty. + * @param bucket the bucket to delete + * @param storeId the store the bucket belongs to. + * @returns true on success or throws an exception. + * @throws CloudRuntimeException if the bucket deletion fails + */ + @Override + public boolean deleteBucket(BucketTO bucket, long storeId) { + AmazonS3 s3client = getS3ClientByBucketAndStore(bucket, storeId); + logger.debug("Deleting bucket {}", bucket.getName()); + try { + s3client.deleteBucket(bucket.getName()); + logger.info("Successfully deleted bucket {}", bucket.getName()); + return true; + } catch (AmazonClientException e) { + logger.error("Failed to delete bucket " + bucket.getName(), e); + throw new CloudRuntimeException(e); + } + } + + @Override + public AccessControlList getBucketAcl(BucketTO bucket, long storeId) { + AmazonS3 s3client = getS3ClientByBucketAndStore(bucket, storeId); + logger.debug("Getting the bucket ACL for {}", bucket.getName()); + try { + AccessControlList acl = s3client.getBucketAcl(bucket.getName()); + logger.info("Successfully got the bucket ACL for {}", bucket.getName()); + return acl; + } catch (AmazonClientException e) { + logger.error("Failed to get the bucket ACL for " + bucket.getName(), e); + throw new CloudRuntimeException(e); + } + } + + @Override + public void setBucketAcl(BucketTO bucket, AccessControlList acl, long storeId) { + AmazonS3 s3client = getS3ClientByBucketAndStore(bucket, storeId); + logger.debug("Setting the bucket ACL for {}", bucket.getName()); + try { + s3client.setBucketAcl(bucket.getName(), acl); + logger.info("Successfully set the bucket ACL for {}", bucket.getName()); + return; + } catch (AmazonClientException e) { + logger.error("Failed to set the bucket ACL for " + bucket.getName(), e); + throw new CloudRuntimeException(e); + } + } + + /** + * Set the bucket policy to either "public" or "private". + * If set to private, we delete any existing policy. + * For public, we allow objects to be read but not listed. + */ + @Override + public void setBucketPolicy(BucketTO bucket, String policy, long storeId) { + if ("private".equalsIgnoreCase(policy)) { + deleteBucketPolicy(bucket, storeId); + return; + } + + StringBuilder sb = new StringBuilder(); + sb.append("{\n"); + sb.append(" \"Version\": \"2012-10-17\",\n"); + sb.append(" \"Statement\": [\n"); + sb.append(" {\n"); + sb.append(" \"Sid\": \"PublicReadForObjects\",\n"); + sb.append(" \"Effect\": \"Allow\",\n"); + sb.append(" \"Principal\": \"*\",\n"); + sb.append(" \"Action\": \"s3:GetObject\",\n"); + sb.append(" \"Resource\": \"arn:aws:s3:::%s/*\"\n"); + sb.append(" }\n"); + sb.append(" ]\n"); + sb.append("}\n"); + + String jsonPolicy = String.format(sb.toString(), bucket.getName()); + + AmazonS3 s3client = getS3ClientByBucketAndStore(bucket, storeId); + logger.debug("Setting the bucket policy to {} for {}", policy, bucket.getName()); + try { + s3client.setBucketPolicy(bucket.getName(), jsonPolicy); + logger.info("Successfully set the bucket policy to {} for {}", policy, bucket.getName()); + return; + } catch (AmazonClientException e) { + logger.error("Failed to set the bucket policy for " + bucket.getName(), e); + throw new CloudRuntimeException(e); + } + } + + @Override + public BucketPolicy getBucketPolicy(BucketTO bucket, long storeId) { + AmazonS3 s3client = getS3ClientByBucketAndStore(bucket, storeId); + logger.debug("Getting the bucket policy for {}", bucket.getName()); + try { + BucketPolicy bp = s3client.getBucketPolicy(bucket.getName()); + logger.info("Successfully got the bucket policy for {}", bucket.getName()); + return bp; + } catch (AmazonClientException e) { + logger.error("Failed to get the bucket policy for " + bucket.getName(), e); + throw new CloudRuntimeException(e); + } + } + + @Override + public void deleteBucketPolicy(BucketTO bucket, long storeId) { + AmazonS3 s3client = getS3ClientByBucketAndStore(bucket, storeId); + logger.debug("Deleting bucket policy for {}", bucket.getName()); + try { + s3client.deleteBucketPolicy(bucket.getName()); + logger.info("Successfully deleted bucket policy for {}", bucket.getName()); + return; + } catch (AmazonClientException e) { + logger.error("Failed to delete bucket policy for " + bucket.getName(), e); + throw new CloudRuntimeException(e); + } + } + + @Override + public boolean setBucketEncryption(BucketTO bucket, long storeId) { + AmazonS3 s3client = getS3ClientByBucketAndStore(bucket, storeId); + logger.debug("Enabling bucket encryption configuration for {}", bucket.getName()); + try { + SetBucketEncryptionRequest eRequest = new SetBucketEncryptionRequest(); + eRequest.setBucketName(bucket.getName()); + + ServerSideEncryptionByDefault sseByDefault = new ServerSideEncryptionByDefault(); + sseByDefault.setSSEAlgorithm(SSEAlgorithm.AES256.toString()); + + ServerSideEncryptionRule sseRule = new ServerSideEncryptionRule(); + sseRule.setApplyServerSideEncryptionByDefault(sseByDefault); + + List sseRules = new ArrayList(); + sseRules.add(sseRule); + + ServerSideEncryptionConfiguration sseConf = new ServerSideEncryptionConfiguration(); + sseConf.setRules(sseRules); + + eRequest.setServerSideEncryptionConfiguration(sseConf); + s3client.setBucketEncryption(eRequest); + + logger.info("Successfully enabled bucket encryption configuration for {}", bucket.getName()); + return true; + } catch (AmazonClientException e) { + logger.error("Failed to enable bucket encryption configuration for " + bucket.getName(), e); + throw new CloudRuntimeException(e); + } + } + + @Override + public boolean deleteBucketEncryption(BucketTO bucket, long storeId) { + AmazonS3 s3client = getS3ClientByBucketAndStore(bucket, storeId); + logger.debug("Deleting bucket encryption configuration for {}", bucket.getName()); + try { + s3client.deleteBucketEncryption(bucket.getName()); + logger.info("Successfully deleted bucket encryption configuration for {}", bucket.getName()); + return true; + } catch (AmazonClientException e) { + logger.error("Failed to delete bucket encryption configuration for " + bucket.getName(), e); + throw new CloudRuntimeException(e); + } + } + + @Override + public boolean setBucketVersioning(BucketTO bucket, long storeId) { + AmazonS3 s3client = getS3ClientByBucketAndStore(bucket, storeId); + logger.debug("Enabling versioning for bucket {}", bucket.getName()); + try { + BucketVersioningConfiguration vConf = new BucketVersioningConfiguration(BucketVersioningConfiguration.ENABLED); + SetBucketVersioningConfigurationRequest vRequest = new SetBucketVersioningConfigurationRequest(bucket.getName(), vConf); + s3client.setBucketVersioningConfiguration(vRequest); + logger.info("Successfully enabled versioning for bucket {}", bucket.getName()); + return true; + } catch (AmazonClientException e) { + logger.error("Failed to enable versioning for bucket " + bucket.getName(), e); + throw new CloudRuntimeException(e); + } + } + + @Override + public boolean deleteBucketVersioning(BucketTO bucket, long storeId) { + AmazonS3 s3client = getS3ClientByBucketAndStore(bucket, storeId); + logger.debug("Suspending versioning for bucket {}", bucket.getName()); + try { + BucketVersioningConfiguration vConf = new BucketVersioningConfiguration(BucketVersioningConfiguration.SUSPENDED); + SetBucketVersioningConfigurationRequest vRequest = new SetBucketVersioningConfigurationRequest(bucket.getName(), vConf); + s3client.setBucketVersioningConfiguration(vRequest); + logger.info("Successfully suspended versioning for bucket {}", bucket.getName()); + return true; + } catch (AmazonClientException e) { + logger.error("Failed to suspend versioning for bucket " + bucket.getName(), e); + throw new CloudRuntimeException(e); + } + } + + /** + * Set the bucket quota to a size limit specified in GiB. + * + * Cloudian HyperStore does not currently support bucket quota limits. + * CloudStack itself requires a quota to be set. HyperStore may add + * Bucket Quota support in a future version. Currently, we only support + * setting the quota to zero to indicate no quota. + * + * @param bucket the bucket + * @param storeId the store + * @param size the GiB (1024^3) size to set the quota to. Only 0 is supported. + * @throws CloudRuntimeException is thrown for any other value other than 0. + */ + @Override + public void setBucketQuota(BucketTO bucket, long storeId, long size) { + if (size == 0) { + logger.debug("Bucket \"{}\" quota set to 0 (no quota).", bucket.getName()); + return; + } + // Any other setting, throw an exception. + logger.warn("Unable to set quota for bucket \"{}\" to {}GiB. Only 0 is supported.", bucket.getName(), size); + throw new CloudRuntimeException("This bucket does not support a quota. Use 0 to specify no quota."); + } + + /** + * Return a map of bucket names managed by this store and their sizes (in bytes). + * + * Note: Bucket Usage Statistics in HyperStore are disabled by default. They + * can be enabled by the HyperStore Administrator by setting of the configuration + * 's3.qos.bucketLevel=true'. If this is not enabled, the values returned will + * either be 0 or out of date. + * + * @return map of bucket names to usage bytes. + */ + @Override + public Map getAllBucketsUsage(long storeId) { + Map bucketUsage = new HashMap(); + List bucketList = _bucketDao.listByObjectStoreId(storeId); + if (bucketList.isEmpty()) { + return bucketUsage; + } + + // Create an unique list of domains from the bucket list + // and add all the bucket names to the bucketUsage map with value -1 as a marker + // to know which buckets CloudStack cares about. The -1 will be replaced later. + List domainIds = new ArrayList(); + for (BucketVO bucket : bucketList) { + long bucketDomainId = bucket.getDomainId(); + if (! domainIds.contains(bucketDomainId)) { + domainIds.add(bucketDomainId); + } + bucketUsage.put(bucket.getName(), -1L); + } + + // Ask for bucket usages per domain (ie. per HyperStore Group) + CloudianClient client = getCloudianClientByStoreId(storeId); + for (long domainId : domainIds) { + Domain domain = _domainDao.findById(domainId); + final String hsGroupId = getHyperStoreGroupId(domain); + List groupBucketUsages = client.getUserBucketUsages(hsGroupId, null, null); + for (CloudianUserBucketUsage userBucketUsages : groupBucketUsages) { + for (CloudianBucketUsage cbu : userBucketUsages.getBuckets()) { + if (cbu.getByteCount() >= 0L) { + // Update the -1 entry to actual byteCount. + bucketUsage.replace(cbu.getBucketName(), cbu.getByteCount()); + } else { + // Replace with 0 instead of actual value. Race condition can cause this and it + // should be fixed automatically by a repair job. + bucketUsage.replace(cbu.getBucketName(), 0L); + logger.info("Ignoring negative bucket usage for \"{}\": {}", cbu.getBucketName(), cbu.getByteCount()); + } + } + } + } + + // Remove any remaining -1 entries. These would probably be buckets that were + // deleted outside of CloudStack control. A missing entry might be better than + // returning the bucket name with -1 or 0. + bucketUsage.entrySet().removeIf(entry -> entry.getValue() == -1); + + return bucketUsage; + } + + /** + * Get a connection to the Cloudian HyperStore ADMIN API Service. + * @param storeId the object store containing connection info for HyperStore + * @return a connection object (never null) + * @throws CloudRuntimeException if the connection fails + */ + protected CloudianClient getCloudianClientByStoreId(long storeId) { + ObjectStoreVO store = _storeDao.findById(storeId); + String url = store.getUrl(); + Map storeDetails = _storeDetailsDao.getDetails(storeId); + String adminUsername = storeDetails.get(CloudianHyperStoreUtil.STORE_DETAILS_KEY_USER_NAME); + String adminPassword = storeDetails.get(CloudianHyperStoreUtil.STORE_DETAILS_KEY_PASSWORD); + String strValidateSSL = storeDetails.get(CloudianHyperStoreUtil.STORE_DETAILS_KEY_VALIDATE_SSL); + boolean validateSSL = Boolean.parseBoolean(strValidateSSL); + + return CloudianHyperStoreUtil.getCloudianClient(url, adminUsername, adminPassword, validateSSL); + } + + /** + * Returns an S3 connection for the store and account identified by the bucket. + * NOTE: https connections must use a trusted certificate. + * + * @param store the object store of the S3 service to connect to + * @param bucket bucket information identifying the account which identifies the credentials to use. + * @return an S3 connection (never null) + * @throws CloudRuntimeException on failure. + */ + protected AmazonS3 getS3ClientByBucketAndStore(BucketTO bucket, long storeId) { + // Find the S3 Root user credentials of the Account Owner rather than using the + // credentials stored with the bucket which may be IAM User Credentials. + for (BucketVO bvo : _bucketDao.listByObjectStoreId(storeId)) { + if (bvo.getName().equals(bucket.getName())) { + long accountId = bvo.getAccountId(); + Map storeDetails = _storeDetailsDao.getDetails(storeId); + String s3url = storeDetails.get(CloudianHyperStoreUtil.STORE_DETAILS_KEY_S3_URL); + String accessKey = _accountDetailsDao.findDetail(accountId, CloudianHyperStoreUtil.KEY_ROOT_ACCESS_KEY).getValue(); + String secretKey = _accountDetailsDao.findDetail(accountId, CloudianHyperStoreUtil.KEY_ROOT_SECRET_KEY).getValue(); + logger.debug("Creating S3 connection to {} for {} ", s3url, accessKey); + return CloudianHyperStoreUtil.getS3Client(s3url, accessKey, secretKey); + } + } + throw new CloudRuntimeException(String.format("Bucket Name not found: %s", bucket.getName())); + } + + /** + * Returns an S3 connection for the given endpoint and credentials. + * NOTE: https connections must use a trusted certificate. + * NOTE: The only reason this wrapper method is here is for unit test mocking. + * + * @param s3url the url of the S3 service + * @param accessKey the credentials to use for the S3 connection. + * @param secretKey the matching secret key. + * @return an S3 connection (never null) + * @throws CloudRuntimeException on failure. + */ + protected AmazonS3 getS3Client(String s3url, String accessKey, String secretKey) { + return CloudianHyperStoreUtil.getS3Client(s3url, accessKey, secretKey); + } + + /** + * Returns an IAM connection for the given store using the given credentials. + * NOTE: if the store uses https, it must use a trusted certificate. + * NOTE: HyperStore IAM service is usually found on ports 16080/16443. + * + * @param storeId the object store + * @param credential the credential pair to use for the iam connection. + * @return an IAM connection (never null) + * @throws CloudRuntimeException on failure. + */ + protected AmazonIdentityManagement getIAMClientByStoreId(long storeId, CloudianCredential credential) { + Map storeDetails = _storeDetailsDao.getDetails(storeId); + String iamUrl = storeDetails.get(CloudianHyperStoreUtil.STORE_DETAILS_KEY_IAM_URL); + logger.debug("Creating a new IAM connection to {} for {}", iamUrl, credential.getAccessKey()); + + return CloudianHyperStoreUtil.getIAMClient(iamUrl, credential.getAccessKey(), credential.getSecretKey()); + } + +} diff --git a/plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/CloudianHyperStoreObjectStoreLifeCycleImpl.java b/plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/CloudianHyperStoreObjectStoreLifeCycleImpl.java new file mode 100644 index 000000000000..d2fda09c65d1 --- /dev/null +++ b/plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/CloudianHyperStoreObjectStoreLifeCycleImpl.java @@ -0,0 +1,151 @@ +// 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. +// SPDX-License-Identifier: Apache-2.0 +package org.apache.cloudstack.storage.datastore.lifecycle; + +import com.cloud.agent.api.StoragePoolInfo; +import com.cloud.hypervisor.Hypervisor.HypervisorType; +import com.cloud.utils.exception.CloudRuntimeException; + +import org.apache.cloudstack.cloudian.client.CloudianClient; +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.util.CloudianHyperStoreUtil; +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 javax.inject.Inject; + +import java.util.HashMap; +import java.util.Map; + +public class CloudianHyperStoreObjectStoreLifeCycleImpl implements ObjectStoreLifeCycle { + + protected Logger logger = LogManager.getLogger(CloudianHyperStoreObjectStoreLifeCycleImpl.class); + + @Inject + ObjectStoreHelper objectStoreHelper; + @Inject + ObjectStoreProviderManager objectStoreMgr; + + public CloudianHyperStoreObjectStoreLifeCycleImpl() { + } + + @Override + public DataStore initialize(Map dsInfos) { + + String name = (String)dsInfos.get(CloudianHyperStoreUtil.STORE_KEY_NAME); + String url = (String)dsInfos.get(CloudianHyperStoreUtil.STORE_KEY_URL); + String providerName = (String)dsInfos.get(CloudianHyperStoreUtil.STORE_KEY_PROVIDER_NAME); + + // Check the providerName is what we expect + if (! StringUtils.equalsIgnoreCase(providerName, CloudianHyperStoreUtil.OBJECT_STORE_PROVIDER_NAME)) { + String msg = String.format("Unexpected providerName \"%s\". Expected \"%s\"", providerName, CloudianHyperStoreUtil.OBJECT_STORE_PROVIDER_NAME); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + + Map objectStoreParameters = new HashMap(); + objectStoreParameters.put(CloudianHyperStoreUtil.STORE_KEY_NAME, name); + objectStoreParameters.put(CloudianHyperStoreUtil.STORE_KEY_URL, url); + objectStoreParameters.put(CloudianHyperStoreUtil.STORE_KEY_PROVIDER_NAME, providerName); + + // Pull out the details map + @SuppressWarnings("unchecked") + Map details = (Map) dsInfos.get(CloudianHyperStoreUtil.STORE_KEY_DETAILS); + if (details == null) { + String msg = String.format("Unexpected null receiving Object Store initialization \"%s\"", CloudianHyperStoreUtil.STORE_KEY_DETAILS); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + + // Note: The Admin Username/Password are available respectively as accesskey/secretkey + String adminUsername = details.get(CloudianHyperStoreUtil.STORE_DETAILS_KEY_USER_NAME); + String adminPassword = details.get(CloudianHyperStoreUtil.STORE_DETAILS_KEY_PASSWORD); + String validateSSL = details.get(CloudianHyperStoreUtil.STORE_DETAILS_KEY_VALIDATE_SSL); + boolean adminValidateSSL = Boolean.parseBoolean(validateSSL); + String s3Url = details.get(CloudianHyperStoreUtil.STORE_DETAILS_KEY_S3_URL); + String iamUrl = details.get(CloudianHyperStoreUtil.STORE_DETAILS_KEY_IAM_URL); + + if (StringUtils.isAnyBlank(adminUsername, adminPassword, validateSSL, s3Url, iamUrl)) { + final String asteriskPassword = (adminPassword == null) ? null : "*".repeat(adminPassword.length()); + logger.error("Required parameters are missing; username={} password={} validateSSL={} s3Url={} iamUrl={}", + adminUsername, asteriskPassword, validateSSL, s3Url, iamUrl); + throw new CloudRuntimeException("Required Cloudian HyperStore configuration parameters are missing/empty."); + } + + // Validate the ADMIN API Service Information + logger.info("Confirming connection to the HyperStore Admin Service at: {}", url); + CloudianClient client = CloudianHyperStoreUtil.getCloudianClient(url, adminUsername, adminPassword, adminValidateSSL); + String version = client.getServerVersion(); + + // Validate S3 and IAM Service URLs. + CloudianHyperStoreUtil.validateS3Url(s3Url); + CloudianHyperStoreUtil.validateIAMUrl(iamUrl); + + logger.info("Successfully connected to HyperStore: {}", version); + + ObjectStoreVO objectStore = objectStoreHelper.createObjectStore(objectStoreParameters, details); + return objectStoreMgr.getObjectStore(objectStore.getId()); + } + + @Override + public boolean attachCluster(DataStore store, ClusterScope scope) { + return false; + } + + @Override + public boolean attachHost(DataStore store, HostScope scope, StoragePoolInfo existingInfo) { + return false; + } + + @Override + public boolean attachZone(DataStore dataStore, ZoneScope scope, HypervisorType hypervisorType) { + return false; + } + + @Override + public boolean maintain(DataStore store) { + return false; + } + + @Override + public boolean cancelMaintain(DataStore store) { + return false; + } + + @Override + public boolean deleteDataStore(DataStore store) { + return false; + } + + /* (non-Javadoc) + * @see org.apache.cloudstack.engine.subsystem.api.storage.DataStoreLifeCycle#migrateToObjectStore(org.apache.cloudstack.engine.subsystem.api.storage.DataStore) + */ + @Override + public boolean migrateToObjectStore(DataStore store) { + return false; + } + +} diff --git a/plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/provider/CloudianHyperStoreObjectStoreProviderImpl.java b/plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/provider/CloudianHyperStoreObjectStoreProviderImpl.java new file mode 100644 index 000000000000..eb924b3b709b --- /dev/null +++ b/plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/provider/CloudianHyperStoreObjectStoreProviderImpl.java @@ -0,0 +1,87 @@ +/* + * 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. + */ +// SPDX-License-Identifier: Apache-2.0 +package org.apache.cloudstack.storage.datastore.provider; + +import com.cloud.utils.component.ComponentContext; +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.CloudianHyperStoreObjectStoreDriverImpl; +import org.apache.cloudstack.storage.datastore.lifecycle.CloudianHyperStoreObjectStoreLifeCycleImpl; +import org.apache.cloudstack.storage.datastore.util.CloudianHyperStoreUtil; +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 javax.inject.Inject; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +@Component +public class CloudianHyperStoreObjectStoreProviderImpl implements ObjectStoreProvider { + + @Inject + ObjectStoreProviderManager storeMgr; + @Inject + ObjectStoreHelper helper; + + private final String providerName = CloudianHyperStoreUtil.OBJECT_STORE_PROVIDER_NAME; + protected ObjectStoreLifeCycle lifeCycle; + protected ObjectStoreDriver driver; + + @Override + public DataStoreLifeCycle getDataStoreLifeCycle() { + return lifeCycle; + } + + @Override + public String getName() { + return this.providerName; + } + + @Override + public boolean configure(Map params) { + lifeCycle = ComponentContext.inject(CloudianHyperStoreObjectStoreLifeCycleImpl.class); + driver = ComponentContext.inject(CloudianHyperStoreObjectStoreDriverImpl.class); + storeMgr.registerDriver(this.getName(), driver); + return true; + } + + @Override + public DataStoreDriver getDataStoreDriver() { + return this.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/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/util/CloudianHyperStoreUtil.java b/plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/util/CloudianHyperStoreUtil.java new file mode 100644 index 000000000000..efa10229428c --- /dev/null +++ b/plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/util/CloudianHyperStoreUtil.java @@ -0,0 +1,211 @@ +// 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. +// SPDX-License-Identifier: Apache-2.0 +package org.apache.cloudstack.storage.datastore.util; + +import java.net.MalformedURLException; +import java.net.URL; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; + +import org.apache.cloudstack.cloudian.client.CloudianClient; +import org.apache.commons.lang3.StringUtils; + +import com.amazonaws.AmazonServiceException; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.identitymanagement.AmazonIdentityManagement; +import com.amazonaws.services.identitymanagement.AmazonIdentityManagementClientBuilder; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.cloud.utils.exception.CloudRuntimeException; + +public class CloudianHyperStoreUtil { + + /** The name of our Object Store Provider */ + public static final String OBJECT_STORE_PROVIDER_NAME = "Cloudian HyperStore"; + + public static final String STORE_KEY_PROVIDER_NAME = "providerName"; + public static final String STORE_KEY_URL = "url"; + public static final String STORE_KEY_NAME = "name"; + public static final String STORE_KEY_DETAILS = "details"; + + // Store Details Map key names - managed outside of plugin + public static final String STORE_DETAILS_KEY_USER_NAME = "accesskey"; // admin user name + public static final String STORE_DETAILS_KEY_PASSWORD = "secretkey"; // admin password + public static final String STORE_DETAILS_KEY_VALIDATE_SSL = "validateSSL"; + public static final String STORE_DETAILS_KEY_S3_URL = "s3Url"; + public static final String STORE_DETAILS_KEY_IAM_URL = "iamUrl"; + + // Account Detail Map key names + public static final String KEY_ROOT_ACCESS_KEY = "hs_AccessKey"; + public static final String KEY_ROOT_SECRET_KEY = "hs_SecretKey"; + public static final String KEY_IAM_ACCESS_KEY = "hs_IAMAccessKey"; + public static final String KEY_IAM_SECRET_KEY = "hs_IAMSecretKey"; + + public static final int DEFAULT_ADMIN_PORT = 19443; + public static final int DEFAULT_ADMIN_TIMEOUT_SECONDS = 10; + + public static final String IAM_USER_USERNAME = "CloudStack"; + public static final String IAM_USER_POLICY_NAME = "CloudStackPolicy"; + public static final String IAM_USER_POLICY = "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Sid\": \"AllowFullS3Access\",\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"s3:*\"\n" + + " ],\n" + + " \"Resource\": \"*\"\n" + + " },\n" + + " {\n" + + " \"Sid\": \"ExceptBucketCreationOrDeletion\",\n" + + " \"Effect\": \"Deny\",\n" + + " \"Action\": [\n" + + " \"s3:createBucket\",\n" + + " \"s3:deleteBucket\"\n" + + " ],\n" + + " \"Resource\": \"*\"\n" + + " }\n" + + " ]\n" + + "}\n"; + + /** + * This method is solely for test purposes so that we can mock the timeout. + * + * @returns the timeout in seconds + */ + protected static int getAdminTimeoutSeconds() { + return DEFAULT_ADMIN_TIMEOUT_SECONDS; + } + + /** + * Get a connection to the Cloudian HyperStore ADMIN API Service. + * @param url the url of the ADMIN API service + * @param user the admin username to connect as + * @param pass the matching admin password + * @param validateSSL validate the SSL Certificate (when using https://) + * @return a connection object (never null) + * @throws CloudRuntimeException if the connection fails for any reason + */ + public static CloudianClient getCloudianClient(String url, String user, String pass, boolean validateSSL) { + try { + URL parsedURL = new URL(url); + String scheme = parsedURL.getProtocol(); + String host = parsedURL.getHost(); + int port = parsedURL.getPort(); + if (port == -1) { + port = DEFAULT_ADMIN_PORT; + } + return new CloudianClient(host, port, scheme, user, pass, validateSSL, getAdminTimeoutSeconds()); + } catch (MalformedURLException | KeyStoreException | NoSuchAlgorithmException | KeyManagementException e) { + throw new CloudRuntimeException(e); + } + } + + /** + * Returns an S3 connection for the given endpoint and credentials. + * NOTE: https connections must use a trusted certificate. + * + * @param url the url of the S3 service + * @param accessKey the credentials to use for the S3 connection. + * @param secretKey the matching secret key. + * @return an S3 connection (never null) + * @throws CloudRuntimeException on failure. + */ + public static AmazonS3 getS3Client(String url, String accessKey, String secretKey) { + AmazonS3 client = AmazonS3ClientBuilder.standard() + .enablePathStyleAccess() + .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKey, secretKey))) + .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(url, "auto")) + .build(); + if (client == null) { + throw new CloudRuntimeException("Error while creating Cloudian S3 client"); + } + return client; + } + + /** + * Returns an IAM connection for the given endpoint and credentials. + * NOTE: https connections must use a trusted certificate. + * NOTE: HyperStore IAM service is usually found on ports 16080/16443. + * + * @param url the url which should include the HyperStore IAM port if not 80/443. + * @param accessKey the credentials to use for the iam connection. + * @param secretKey the matching secret key. + * @return an IAM connection (never null) + * @throws CloudRuntimeException on failure. + */ + public static AmazonIdentityManagement getIAMClient(String url, String accessKey, String secretKey) { + AmazonIdentityManagement iamClient = AmazonIdentityManagementClientBuilder.standard() + .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKey, secretKey))) + .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(url, "auto")) + .build(); + if (iamClient == null) { + throw new CloudRuntimeException("Error while creating Cloudian IAM client"); + } + return iamClient; + } + + /** + * Test the S3Url to confirm it behaves like an S3 Service. + * + * The method uses bad credentials and looks for the particular error from S3 + * that says InvalidAccessKeyId was used. The method quietly returns if + * we connect and get the expected error back. + * + * @param s3Url the url to check + * + * @throws RuntimeException if there is any issue. + */ + public static void validateS3Url(String s3Url) { + try { + AmazonS3 s3Client = CloudianHyperStoreUtil.getS3Client(s3Url, "unknown", "unknown"); + s3Client.listBuckets(); + } catch (AmazonServiceException e) { + // Check if the ErrorCode says that the access key (we used "unknown" was invalid + if (StringUtils.compareIgnoreCase(e.getErrorCode(), "InvalidAccessKeyId") != 0) { + throw new CloudRuntimeException("Unexpected response from S3 Endpoint.", e); + } + } + } + + /** + * Test the IAMUrl to confirm it behaves like an IAM Service. + * + * The method uses bad credentials and looks for the particular error from IAM + * that says InvalidAccessKeyId or InvalidClientTokenId was used. The method quietly + * returns if we connect and get the expected error back. + * + * @param iamUrl the url to check + * + * @throws RuntimeException if there is any issue. + */ + public static void validateIAMUrl(String iamUrl) { + try { + AmazonIdentityManagement iamClient = CloudianHyperStoreUtil.getIAMClient(iamUrl, "unknown", "unknown"); + iamClient.listAccessKeys(); + } catch (AmazonServiceException e) { + if (! StringUtils.equalsAnyIgnoreCase(e.getErrorCode(), "InvalidAccessKeyId", "InvalidClientTokenId")) { + throw new CloudRuntimeException("Unexpected response from IAM Endpoint.", e); + } + } + } +} diff --git a/plugins/storage/object/cloudian/src/main/resources/META-INF/cloudstack/storage-object-cloudian/module.properties b/plugins/storage/object/cloudian/src/main/resources/META-INF/cloudstack/storage-object-cloudian/module.properties new file mode 100644 index 000000000000..c09ff9cedccf --- /dev/null +++ b/plugins/storage/object/cloudian/src/main/resources/META-INF/cloudstack/storage-object-cloudian/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-cloudian +parent=storage diff --git a/plugins/storage/object/cloudian/src/main/resources/META-INF/cloudstack/storage-object-cloudian/spring-storage-object-cloudian-context.xml b/plugins/storage/object/cloudian/src/main/resources/META-INF/cloudstack/storage-object-cloudian/spring-storage-object-cloudian-context.xml new file mode 100644 index 000000000000..b8ffb8aef42e --- /dev/null +++ b/plugins/storage/object/cloudian/src/main/resources/META-INF/cloudstack/storage-object-cloudian/spring-storage-object-cloudian-context.xml @@ -0,0 +1,31 @@ + + + + diff --git a/plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/driver/CloudianHyperStoreObjectStoreDriverImplTest.java b/plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/driver/CloudianHyperStoreObjectStoreDriverImplTest.java new file mode 100644 index 000000000000..1cf99ca53465 --- /dev/null +++ b/plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/driver/CloudianHyperStoreObjectStoreDriverImplTest.java @@ -0,0 +1,686 @@ +// 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. +// SPDX-License-Identifier: Apache-2.0 +package org.apache.cloudstack.storage.datastore.driver; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.cloudian.client.CloudianClient; +import org.apache.cloudstack.cloudian.client.CloudianCredential; +import org.apache.cloudstack.cloudian.client.CloudianGroup; +import org.apache.cloudstack.cloudian.client.CloudianUser; +import org.apache.cloudstack.cloudian.client.CloudianUserBucketUsage; +import org.apache.cloudstack.cloudian.client.CloudianUserBucketUsage.CloudianBucketUsage; +import org.apache.cloudstack.storage.datastore.db.ObjectStoreDao; +import org.apache.cloudstack.storage.datastore.db.ObjectStoreDetailsDao; +import org.apache.cloudstack.storage.datastore.db.ObjectStoreVO; +import org.apache.cloudstack.storage.datastore.util.CloudianHyperStoreUtil; +import org.apache.cloudstack.storage.object.Bucket; + +import com.amazonaws.services.identitymanagement.AmazonIdentityManagement; +import com.amazonaws.services.identitymanagement.model.AccessKey; +import com.amazonaws.services.identitymanagement.model.AccessKeyMetadata; +import com.amazonaws.services.identitymanagement.model.CreateAccessKeyRequest; +import com.amazonaws.services.identitymanagement.model.CreateAccessKeyResult; +import com.amazonaws.services.identitymanagement.model.CreateUserRequest; +import com.amazonaws.services.identitymanagement.model.ListAccessKeysRequest; +import com.amazonaws.services.identitymanagement.model.ListAccessKeysResult; +import com.amazonaws.services.identitymanagement.model.PutUserPolicyRequest; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.CreateBucketRequest; +import com.amazonaws.services.s3.model.ServerSideEncryptionRule; +import com.amazonaws.services.s3.model.SetBucketCrossOriginConfigurationRequest; +import com.amazonaws.services.s3.model.SetBucketEncryptionRequest; +import com.amazonaws.services.s3.model.SetBucketVersioningConfigurationRequest; +import com.cloud.agent.api.to.BucketTO; +import com.cloud.domain.DomainVO; +import com.cloud.domain.dao.DomainDao; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.storage.BucketVO; +import com.cloud.storage.dao.BucketDao; +import com.cloud.user.AccountDetailVO; +import com.cloud.user.AccountDetailsDao; +import com.cloud.user.AccountVO; +import com.cloud.user.dao.AccountDao; +import com.cloud.utils.exception.CloudRuntimeException; + +@RunWith(MockitoJUnitRunner.class) +public class CloudianHyperStoreObjectStoreDriverImplTest { + + @Spy + CloudianHyperStoreObjectStoreDriverImpl cloudianHyperStoreObjectStoreDriverImpl = new CloudianHyperStoreObjectStoreDriverImpl(); + + @Mock + AmazonS3 s3Client; + @Mock + CloudianClient cloudianClient; + @Mock + AmazonIdentityManagement iamClient; + @Mock + ObjectStoreDao objectStoreDao; + @Mock + ObjectStoreVO objectStoreVO; + @Mock + ObjectStoreDetailsDao objectStoreDetailsDao; + @Mock + AccountDao accountDao; + @Mock + BucketDao bucketDao; + @Mock + DomainDao domainDao; + @Mock + AccountDetailsDao accountDetailsDao; + + @Mock + AccountVO account; + @Mock + DomainVO domain; + + BucketVO bucketVo; + Map StoreDetailsMap; + Map AccountDetailsMap; + + static long TEST_STORE_ID = 1010L; + static long TEST_ACCOUNT_ID = 2010L; + static long TEST_DOMAIN_ID = 3010L; + static String TEST_ADMIN_URL = "https://admin-endpoint:19443"; + static String TEST_ADMIN_USER_NAME = "test_admin"; + static String TEST_ADMIN_PASSWORD = "test_password"; + static String TEST_ADMIN_VALIDATE_SSL = "true"; + static String TEST_BUCKET_NAME = "testbucketname"; + static String TEST_ROOT_AK = "root_access_key"; + static String TEST_ROOT_SK = "root_secret_key"; + static String TEST_IAM_AK = "iam_access_key"; + static String TEST_IAM_SK = "iam_secret_key"; + static String TEST_S3_URL = "http://s3-endpoint"; + static String TEST_IAM_URL = "http://iam-endpoint:16080"; + static String TEST_BUCKET_URL = TEST_S3_URL + "/" + TEST_BUCKET_NAME; + + private AutoCloseable closeable; + + @Before + public void setUp() { + closeable = MockitoAnnotations.openMocks(this); + cloudianHyperStoreObjectStoreDriverImpl._storeDao = objectStoreDao; + cloudianHyperStoreObjectStoreDriverImpl._storeDetailsDao = objectStoreDetailsDao; + cloudianHyperStoreObjectStoreDriverImpl._accountDao = accountDao; + cloudianHyperStoreObjectStoreDriverImpl._bucketDao = bucketDao; + cloudianHyperStoreObjectStoreDriverImpl._accountDetailsDao = accountDetailsDao; + cloudianHyperStoreObjectStoreDriverImpl._domainDao = domainDao; + + // Setup to return the store url for cloudianClient + when(objectStoreDao.findById(TEST_STORE_ID)).thenReturn(objectStoreVO); + when(objectStoreVO.getUrl()).thenReturn(TEST_ADMIN_URL); + + // The StoreDetailMap has Endpoint info and Admin Credentials + StoreDetailsMap = new HashMap(); + StoreDetailsMap.put(CloudianHyperStoreUtil.STORE_DETAILS_KEY_USER_NAME, TEST_ADMIN_USER_NAME); + StoreDetailsMap.put(CloudianHyperStoreUtil.STORE_DETAILS_KEY_PASSWORD, TEST_ADMIN_PASSWORD); + StoreDetailsMap.put(CloudianHyperStoreUtil.STORE_DETAILS_KEY_VALIDATE_SSL, TEST_ADMIN_VALIDATE_SSL); + StoreDetailsMap.put(CloudianHyperStoreUtil.STORE_DETAILS_KEY_S3_URL, TEST_S3_URL); + StoreDetailsMap.put(CloudianHyperStoreUtil.STORE_DETAILS_KEY_IAM_URL, TEST_IAM_URL); + when(objectStoreDetailsDao.getDetails(TEST_STORE_ID)).thenReturn(StoreDetailsMap); + + // The AccountDetailsMap has credentials for operating on the account. + AccountDetailsMap = new HashMap(); + AccountDetailsMap.put(CloudianHyperStoreUtil.KEY_ROOT_ACCESS_KEY, TEST_ROOT_AK); + AccountDetailsMap.put(CloudianHyperStoreUtil.KEY_ROOT_SECRET_KEY, TEST_ROOT_SK); + AccountDetailsMap.put(CloudianHyperStoreUtil.KEY_IAM_ACCESS_KEY, TEST_IAM_AK); + AccountDetailsMap.put(CloudianHyperStoreUtil.KEY_IAM_SECRET_KEY, TEST_IAM_SK); + when(accountDetailsDao.findDetails(TEST_ACCOUNT_ID)).thenReturn(AccountDetailsMap); + + // Useful test bucket info + bucketVo = new BucketVO(TEST_ACCOUNT_ID, TEST_DOMAIN_ID, TEST_STORE_ID, TEST_BUCKET_NAME, null, false, false, false, null); + } + + @After + public void tearDown() throws Exception { + closeable.close(); + } + + @Test + public void testGetStoreTO() { + assertNull(cloudianHyperStoreObjectStoreDriverImpl.getStoreTO(null)); + } + + @Test + public void testCreateBucket() throws Exception { + doReturn(s3Client).when(cloudianHyperStoreObjectStoreDriverImpl).getS3Client(anyString(), anyString(), anyString()); + when(bucketDao.findById(anyLong())).thenReturn(bucketVo); + + // Actual Test + Bucket bucketRet = cloudianHyperStoreObjectStoreDriverImpl.createBucket(bucketVo, false); + assertEquals(TEST_BUCKET_NAME, bucketRet.getName()); + + // Capture the bucket info that was saved to the DB + ArgumentCaptor argument = ArgumentCaptor.forClass(BucketVO.class); + verify(bucketDao, times(1)).update(any(), argument.capture()); + BucketVO UpdatedBucketVO = argument.getValue(); + assertEquals(TEST_IAM_AK, UpdatedBucketVO.getAccessKey()); + assertEquals(TEST_IAM_SK, UpdatedBucketVO.getSecretKey()); + assertEquals(TEST_BUCKET_URL, UpdatedBucketVO.getBucketURL()); + + verify(s3Client, times(1)).createBucket(any(CreateBucketRequest.class)); + verify(s3Client, times(1)) + .setBucketCrossOriginConfiguration(any(SetBucketCrossOriginConfigurationRequest.class)); + verify(s3Client, never()).deleteBucket(anyString()); + } + + @Test + public void testCreateHSCredential() throws Exception { + cloudianClient = mock(CloudianClient.class); + List CredList = new ArrayList(); + CloudianCredential c1 = new CloudianCredential(); + c1.setActive(false); + c1.setCreateDate(new Date(1L)); // oldest but inactive + CloudianCredential c2 = new CloudianCredential(); + c2.setAccessKey(TEST_ROOT_AK); + c2.setSecretKey(TEST_ROOT_SK); + c2.setActive(true); + c2.setCreateDate(new Date(2L)); // 2nd oldest + CloudianCredential c3 = new CloudianCredential(); + c3.setActive(true); + c3.setCreateDate(new Date(2L)); // newest + CredList.add(c1); + CredList.add(c2); + CredList.add(c3); + when(cloudianClient.listCredentials(anyString(), anyString())).thenReturn(CredList); + + // Test expects c2 which is the oldest active credential. + CloudianCredential actual = cloudianHyperStoreObjectStoreDriverImpl.createHSCredential(cloudianClient, "user", "group"); + assertTrue(actual.getActive()); + assertEquals(TEST_ROOT_AK, actual.getAccessKey()); + assertEquals(TEST_ROOT_SK, actual.getSecretKey()); + verify(cloudianClient, never()).createCredential(anyString(), anyString()); + } + + @Test + public void testGetAllBucketsUsageNoBuckets() throws Exception { + when(bucketDao.listByObjectStoreId(TEST_STORE_ID)).thenReturn(new ArrayList()); + Map emptyMap = cloudianHyperStoreObjectStoreDriverImpl.getAllBucketsUsage(TEST_STORE_ID); + assertNotNull(emptyMap); + assertEquals(0, emptyMap.size()); + } + + @Test + public void testGetAllBucketsUsageTwoDomains() { + // Prepare Buckets the store knows about. + BucketVO b1 = new BucketVO(TEST_ACCOUNT_ID, 1L, TEST_STORE_ID, "b1", null, false, false, false, null); + BucketVO b2 = new BucketVO(TEST_ACCOUNT_ID, 1L, TEST_STORE_ID, "b2", null, false, false, false, null); + BucketVO b3 = new BucketVO(TEST_ACCOUNT_ID, 2L, TEST_STORE_ID, "b3", null, false, false, false, null); + BucketVO b4 = new BucketVO(TEST_ACCOUNT_ID, 2L, TEST_STORE_ID, "b4", null, false, false, false, null); + List BucketList = new ArrayList(); + BucketList.add(b1); // b1 owned by domain 1, exists + BucketList.add(b2); // b2 owned by domain 1, deleted in object store (so no usage info) + BucketList.add(b3); // b3 owned by domain 2, exists + BucketList.add(b4); // b4 owned by domain 2, exists + when(bucketDao.listByObjectStoreId(TEST_STORE_ID)).thenReturn(BucketList); + + final String hsGroupId1 = "domain1"; + final String hsGroupId2 = "domain2"; + + // Setup both domains d1 and d2 with uuids that will become hsGroupId + DomainVO d1 = mock(DomainVO.class); + when(d1.getUuid()).thenReturn(hsGroupId1); + DomainVO d2 = mock(DomainVO.class); + when(d2.getUuid()).thenReturn(hsGroupId2); + when(domainDao.findById(1L)).thenReturn(d1); + when(domainDao.findById(2L)).thenReturn(d2); + + // Setup Bucket Usage Data returned for b1, b3, b4, b5 by CloudianClient + // where b2 is missing, b4 usage is negative and b5 is unknown. + CloudianBucketUsage bu1 = new CloudianBucketUsage(); + bu1.setBucketName("b1"); + bu1.setByteCount(1L); + CloudianBucketUsage bu3 = new CloudianBucketUsage(); + bu3.setBucketName("b3"); + bu3.setByteCount(3L); + CloudianBucketUsage bu4 = new CloudianBucketUsage(); + bu4.setBucketName("b4"); + bu4.setByteCount(-55555L); + CloudianBucketUsage bu5 = new CloudianBucketUsage(); + bu5.setBucketName("b5"); + bu5.setByteCount(5L); + List d1bucketList = new ArrayList(); + d1bucketList.add(bu1); + List d2bucketList = new ArrayList(); + d2bucketList.add(bu3); + d2bucketList.add(bu4); + d2bucketList.add(bu5); + CloudianUserBucketUsage d1U1Usage = mock(CloudianUserBucketUsage.class); + when(d1U1Usage.getBuckets()).thenReturn(d1bucketList); + CloudianUserBucketUsage d2U1Usage = mock(CloudianUserBucketUsage.class); + when(d2U1Usage.getBuckets()).thenReturn(d2bucketList); + List d1Usage = new ArrayList(); + d1Usage.add(d1U1Usage); + List d2Usage = new ArrayList(); + d2Usage.add(d2U1Usage); + + doReturn(cloudianClient).when(cloudianHyperStoreObjectStoreDriverImpl).getCloudianClientByStoreId(TEST_STORE_ID); + when(cloudianClient.getUserBucketUsages(hsGroupId1, null, null)).thenReturn(d1Usage); + when(cloudianClient.getUserBucketUsages(hsGroupId2, null, null)).thenReturn(d2Usage); + + // Test Details: + // The CloudStack DB knows about 4 buckets: b1, b2, b3, b4 + // The actual Object Store knows about 4 buckets: b1, b3, b4, b5 + // Bucket usage in Object Store is: b1:1, b3:3, b4:-55555, b5:5 + // Expected Response: Usage for 3 buckets, b1, b3 and b4 where + // b4 usage is returns as 0 instead of actual negative value and + // b5 is ignored as it is not known by the store. + Map usageMap = cloudianHyperStoreObjectStoreDriverImpl.getAllBucketsUsage(TEST_STORE_ID); + assertNotNull(usageMap); + assertEquals(3, usageMap.size()); + assertEquals(1L, usageMap.get("b1").longValue()); + assertEquals(3L, usageMap.get("b3").longValue()); + assertEquals(0L, usageMap.get("b4").longValue()); + } + + @Test + public void testCreateUserNotExists() throws Exception { + // ensure no account credentials are returned in the account details for new user. + Mockito.reset(accountDetailsDao); + when(accountDetailsDao.findDetails(TEST_ACCOUNT_ID)).thenReturn(new HashMap()); + + String hsUserId = "user1"; + String hsGroupId = "group1"; + when(accountDao.findById(TEST_ACCOUNT_ID)).thenReturn(account); + when(account.getDomainId()).thenReturn(TEST_DOMAIN_ID); + when(account.getUuid()).thenReturn(hsUserId); + when(domainDao.findById(TEST_DOMAIN_ID)).thenReturn(domain); + when(domain.getUuid()).thenReturn(hsGroupId); + + doReturn(cloudianClient).when(cloudianHyperStoreObjectStoreDriverImpl).getCloudianClientByStoreId(TEST_STORE_ID); + + // Setup the user and group as not found. + when(cloudianClient.listUser(hsUserId, hsGroupId)).thenReturn(null); + when(cloudianClient.listGroup(hsGroupId)).thenReturn(null); + when(cloudianClient.addUser(any(CloudianUser.class))).thenReturn(true); + // lets assume no credentials added, so we add new ones. + when(cloudianClient.listCredentials(hsUserId, hsGroupId)).thenReturn(new ArrayList()); + CloudianCredential credential = new CloudianCredential(); + credential.setAccessKey(TEST_ROOT_AK); + credential.setSecretKey(TEST_ROOT_SK); + when(cloudianClient.createCredential(hsUserId, hsGroupId)).thenReturn(credential); + + // Setup IAM for user, policy and credential creation. + doReturn(iamClient).when(cloudianHyperStoreObjectStoreDriverImpl).getIAMClientByStoreId(TEST_STORE_ID, credential); + AccessKey accessKey = mock(AccessKey.class); + CreateAccessKeyResult accessKeyResult = mock(CreateAccessKeyResult.class); + when(accessKey.getAccessKeyId()).thenReturn(TEST_IAM_AK); + when(accessKey.getSecretAccessKey()).thenReturn(TEST_IAM_SK); + when(accessKeyResult.getAccessKey()).thenReturn(accessKey); + when(iamClient.createAccessKey(any(CreateAccessKeyRequest.class))).thenReturn(accessKeyResult); + + // Next Check what will be persisted in DB after everything created. + // Even though its not going to be true for a new user, lets have 1 bucket + // whose credentials need to be updated. + BucketVO bucketToUpdate = mock(BucketVO.class); + when(bucketToUpdate.getId()).thenReturn(9L); + List bucketUpdateList = new ArrayList(); + bucketUpdateList.add(bucketToUpdate); + when(bucketDao.listByObjectStoreIdAndAccountId(TEST_STORE_ID, TEST_ACCOUNT_ID)).thenReturn(bucketUpdateList); + + // Test: The user should be created which involves: + // creating the group, user and root credentials + // creating the iam user, its policy and iam credentials + // finally persisting the root and iam credentials in account details. + boolean created = cloudianHyperStoreObjectStoreDriverImpl.createUser(TEST_ACCOUNT_ID, TEST_STORE_ID); + assertTrue(created); + + // THe HyperStore group, user and credentials + verify(cloudianClient, times(1)).addGroup(any(CloudianGroup.class)); + verify(cloudianClient, times(1)).addUser(any(CloudianUser.class)); + verify(cloudianClient, times(1)).createCredential(hsUserId, hsGroupId); + + // not expecting IAM list access keys for a new user. + verify(iamClient, never()).listAccessKeys(any(ListAccessKeysRequest.class)); + // We do expect IAM user creation with policy and access keys though. + verify(iamClient, times(1)).createUser(any(CreateUserRequest.class)); + verify(iamClient, times(1)).putUserPolicy(any(PutUserPolicyRequest.class)); + verify(iamClient, times(1)).createAccessKey(any(CreateAccessKeyRequest.class)); + + // Now let's verify that the correct account details were persisted. + ArgumentCaptor> detailsArg = ArgumentCaptor.forClass((Class>) (Class) Map.class); + verify(accountDetailsDao, times(1)).persist(anyLong(), detailsArg.capture()); + Map updatedDetails = detailsArg.getValue(); + assertEquals(4, updatedDetails.size()); + assertEquals(TEST_IAM_AK, updatedDetails.get(CloudianHyperStoreUtil.KEY_IAM_ACCESS_KEY)); + assertEquals(TEST_IAM_SK, updatedDetails.get(CloudianHyperStoreUtil.KEY_IAM_SECRET_KEY)); + assertEquals(TEST_ROOT_AK, updatedDetails.get(CloudianHyperStoreUtil.KEY_ROOT_ACCESS_KEY)); + assertEquals(TEST_ROOT_SK, updatedDetails.get(CloudianHyperStoreUtil.KEY_ROOT_SECRET_KEY)); + + // Also verify that bucketToUpdate was updated with new credentials. + verify(bucketToUpdate, times(1)).setAccessKey(anyString()); + verify(bucketToUpdate, times(1)).setSecretKey(anyString()); + verify(bucketDao, times(1)).update(9L, bucketToUpdate); + } + + @Test + public void testCreateUserExists() { + String hsUserId = "user1"; + String hsGroupId = "group1"; + when(accountDao.findById(TEST_ACCOUNT_ID)).thenReturn(account); + when(account.getDomainId()).thenReturn(TEST_DOMAIN_ID); + when(account.getUuid()).thenReturn(hsUserId); + when(domainDao.findById(TEST_DOMAIN_ID)).thenReturn(domain); + when(domain.getUuid()).thenReturn(hsGroupId); + + doReturn(cloudianClient).when(cloudianHyperStoreObjectStoreDriverImpl).getCloudianClientByStoreId(TEST_STORE_ID); + + // Setup the user/group as existing and active + CloudianUser user = mock(CloudianUser.class); + CloudianGroup group = mock(CloudianGroup.class); + when(user.getActive()).thenReturn(true); + when(group.getActive()).thenReturn(true); + when(cloudianClient.listUser(hsUserId, hsGroupId)).thenReturn(user); + when(cloudianClient.listGroup(hsGroupId)).thenReturn(group); + + // Setup the HS Credential to match known Root credential + CloudianCredential credential = new CloudianCredential(); + credential.setAccessKey(TEST_ROOT_AK); + credential.setSecretKey(TEST_ROOT_SK); + credential.setActive(true); + credential.setCreateDate(new Date(1L)); + List credentials = new ArrayList(); + credentials.add(credential); + when(cloudianClient.listCredentials(hsUserId, hsGroupId)).thenReturn(credentials); + + // Setup IAM to return 2 credentials, one that matches and one that doesn't + doReturn(iamClient).when(cloudianHyperStoreObjectStoreDriverImpl).getIAMClientByStoreId(TEST_STORE_ID, credential); + ListAccessKeysResult listAccessKeyResult = mock(ListAccessKeysResult.class); + List listAccessKeyMetadata = new ArrayList(); + AccessKeyMetadata accessKeyNoMatch = mock(AccessKeyMetadata.class); + when(accessKeyNoMatch.getAccessKeyId()).thenReturn("no_match"); + AccessKeyMetadata accessKeyMatch = mock(AccessKeyMetadata.class); + when(accessKeyMatch.getAccessKeyId()).thenReturn(TEST_IAM_AK); + listAccessKeyMetadata.add(accessKeyNoMatch); + listAccessKeyMetadata.add(accessKeyMatch); + when(listAccessKeyResult.getAccessKeyMetadata()).thenReturn(listAccessKeyMetadata); + when(iamClient.listAccessKeys(any())).thenReturn(listAccessKeyResult); + + // Test: The user should exist and nothing needs to be created + // or persisted. There is one misc IAM credential to clean up. + boolean created = cloudianHyperStoreObjectStoreDriverImpl.createUser(TEST_ACCOUNT_ID, TEST_STORE_ID); + assertTrue(created); + + // THe No HyperStore user, group or credentials were created. + verify(cloudianClient, never()).addGroup(any(CloudianGroup.class)); + verify(cloudianClient, never()).addUser(any(CloudianUser.class)); + verify(cloudianClient, never()).createCredential(hsUserId, hsGroupId); + + // List access keys finds 2 do deletes 1 that doesn't match. + verify(iamClient, times(1)).listAccessKeys(any()); + verify(iamClient, times(1)).deleteAccessKey(any()); + + // And we don't create anything IAM related either. + verify(iamClient, never()).createUser(any()); + verify(iamClient, never()).putUserPolicy(any()); + verify(iamClient, never()).createAccessKey(any()); + + // Nothing needs to be persisted. + verify(accountDetailsDao, never()).persist(anyLong(), anyMap()); + } + + @Test + public void testCreateUserDisabledUserExists() { + String hsUserId = "user1"; + String hsGroupId = "group1"; + when(accountDao.findById(TEST_ACCOUNT_ID)).thenReturn(account); + when(account.getDomainId()).thenReturn(TEST_DOMAIN_ID); + when(account.getUuid()).thenReturn(hsUserId); + when(domainDao.findById(TEST_DOMAIN_ID)).thenReturn(domain); + when(domain.getUuid()).thenReturn(hsGroupId); + + doReturn(cloudianClient).when(cloudianHyperStoreObjectStoreDriverImpl).getCloudianClientByStoreId(TEST_STORE_ID); + + // Setup the user to be found but inactive. + CloudianUser user = mock(CloudianUser.class); + when(user.getActive()).thenReturn(false); + when(cloudianClient.listUser(hsUserId, hsGroupId)).thenReturn(user); + + // Test: user exists but is disabled. This condition requires HyperStore administrator action. + CloudRuntimeException thrown = assertThrows(CloudRuntimeException.class, () -> cloudianHyperStoreObjectStoreDriverImpl.createUser(TEST_ACCOUNT_ID, TEST_STORE_ID)); + assertTrue(thrown.getMessage().contains("is Disabled. Consult")); + } + + @Test + public void testCreateUserDisabledGroupExists() { + String hsUserId = "user1"; + String hsGroupId = "group1"; + when(accountDao.findById(TEST_ACCOUNT_ID)).thenReturn(account); + when(account.getDomainId()).thenReturn(TEST_DOMAIN_ID); + when(account.getUuid()).thenReturn(hsUserId); + when(domainDao.findById(TEST_DOMAIN_ID)).thenReturn(domain); + when(domain.getUuid()).thenReturn(hsGroupId); + + doReturn(cloudianClient).when(cloudianHyperStoreObjectStoreDriverImpl).getCloudianClientByStoreId(TEST_STORE_ID); + + // Setup the user to not be found so that we check for a group + when(cloudianClient.listUser(hsUserId, hsGroupId)).thenReturn(null); + CloudianGroup group = mock(CloudianGroup.class); + when(group.getActive()).thenReturn(false); + when(cloudianClient.listGroup(hsGroupId)).thenReturn(group); + + // Test: user does not exist, check if group exists, it does but is marked disabled. + CloudRuntimeException thrown = assertThrows(CloudRuntimeException.class, () -> cloudianHyperStoreObjectStoreDriverImpl.createUser(TEST_ACCOUNT_ID, TEST_STORE_ID)); + assertTrue(thrown.getMessage().contains(String.format("The group %s is Disabled. Consult", hsGroupId))); + } + + @Test + public void testListBuckets() { + Map bucketUsageMap = new HashMap(); + bucketUsageMap.put("b1", 1L); + bucketUsageMap.put("b2", 2L); + when(cloudianHyperStoreObjectStoreDriverImpl.getAllBucketsUsage(anyLong())).thenReturn(bucketUsageMap); + + List bucketList = cloudianHyperStoreObjectStoreDriverImpl.listBuckets(TEST_STORE_ID); + assertNotNull(bucketList); + assertEquals(2, bucketList.size()); + } + + @Test + public void testDeleteBucket() throws Exception { + doReturn(s3Client).when(cloudianHyperStoreObjectStoreDriverImpl).getS3ClientByBucketAndStore(any(), anyLong()); + BucketTO bucket = mock(BucketTO.class); + when(bucket.getName()).thenReturn(TEST_BUCKET_NAME); + boolean deleted = cloudianHyperStoreObjectStoreDriverImpl.deleteBucket(bucket, TEST_STORE_ID); + assertTrue(deleted); + verify(s3Client, times(1)).deleteBucket(TEST_BUCKET_NAME); + } + + @Test + public void testSetBucketPolicyPrivate() throws Exception { + doReturn(s3Client).when(cloudianHyperStoreObjectStoreDriverImpl).getS3ClientByBucketAndStore(any(), anyLong()); + BucketTO bucket = mock(BucketTO.class); + when(bucket.getName()).thenReturn(TEST_BUCKET_NAME); + cloudianHyperStoreObjectStoreDriverImpl.setBucketPolicy(bucket, "private", TEST_STORE_ID); + // private policy is equivalent to deleting any bucket policy + verify(s3Client, times(1)).deleteBucketPolicy(TEST_BUCKET_NAME); + verify(s3Client, never()).setBucketPolicy(anyString(), anyString()); + } + + @Test + public void testSetBucketPolicyPublic() throws Exception { + doReturn(s3Client).when(cloudianHyperStoreObjectStoreDriverImpl).getS3ClientByBucketAndStore(any(), anyLong()); + BucketTO bucket = mock(BucketTO.class); + when(bucket.getName()).thenReturn(TEST_BUCKET_NAME); + cloudianHyperStoreObjectStoreDriverImpl.setBucketPolicy(bucket, "public", TEST_STORE_ID); + verify(s3Client, times(1)).setBucketPolicy(anyString(), anyString()); + verify(s3Client, never()).deleteBucketPolicy(TEST_BUCKET_NAME); + } + + @Test(expected = CloudRuntimeException.class) + public void testSetBucketQuotaNonZero() { + BucketTO bucket = mock(BucketTO.class); + when(bucket.getName()).thenReturn(TEST_BUCKET_NAME); + // Quota is not implemented by HyperStore. Throws a CloudRuntimeException if not 0. + cloudianHyperStoreObjectStoreDriverImpl.setBucketQuota(bucket, TEST_STORE_ID, 5L); + } + + public void testSetBucketQuotaToZero() { + BucketTO bucket = mock(BucketTO.class); + when(bucket.getName()).thenReturn(TEST_BUCKET_NAME); + // A zero quota indicates no quota and should be an accepted value. + cloudianHyperStoreObjectStoreDriverImpl.setBucketQuota(bucket, TEST_STORE_ID, 0); + } + + @Test + public void testSetBucketEncryption() { + doReturn(s3Client).when(cloudianHyperStoreObjectStoreDriverImpl).getS3ClientByBucketAndStore(any(), anyLong()); + BucketTO bucket = mock(BucketTO.class); + when(bucket.getName()).thenReturn(TEST_BUCKET_NAME); + cloudianHyperStoreObjectStoreDriverImpl.setBucketEncryption(bucket, TEST_STORE_ID); + + // setBucketEncryption should be called once with SSE set. + ArgumentCaptor arg = ArgumentCaptor.forClass(SetBucketEncryptionRequest.class); + verify(s3Client, times(1)).setBucketEncryption(arg.capture()); + SetBucketEncryptionRequest request = arg.getValue(); + List rules = request.getServerSideEncryptionConfiguration().getRules(); + assertEquals(1, rules.size()); + assertEquals("AES256", rules.get(0).getApplyServerSideEncryptionByDefault().getSSEAlgorithm()); + } + + @Test + public void testDeleteBucketEncryption() throws Exception { + doReturn(s3Client).when(cloudianHyperStoreObjectStoreDriverImpl).getS3ClientByBucketAndStore(any(), anyLong()); + BucketTO bucket = mock(BucketTO.class); + when(bucket.getName()).thenReturn(TEST_BUCKET_NAME); + boolean deleted = cloudianHyperStoreObjectStoreDriverImpl.deleteBucketEncryption(bucket, TEST_STORE_ID); + assertTrue(deleted); + verify(s3Client, times(1)).deleteBucketEncryption(TEST_BUCKET_NAME); + } + + @Test + public void testSetBucketVersioning() throws Exception { + doReturn(s3Client).when(cloudianHyperStoreObjectStoreDriverImpl).getS3ClientByBucketAndStore(any(), anyLong()); + BucketTO bucket = mock(BucketTO.class); + when(bucket.getName()).thenReturn(TEST_BUCKET_NAME); + ArgumentCaptor arg = ArgumentCaptor.forClass(SetBucketVersioningConfigurationRequest.class); + + boolean set = cloudianHyperStoreObjectStoreDriverImpl.setBucketVersioning(bucket, TEST_STORE_ID); + assertTrue(set); + verify(s3Client, times(1)).setBucketVersioningConfiguration(arg.capture()); + SetBucketVersioningConfigurationRequest request = arg.getValue(); + assertEquals(TEST_BUCKET_NAME, request.getBucketName()); + assertEquals("Enabled", request.getVersioningConfiguration().getStatus()); + } + + @Test + public void testDeleteBucketVersioning() throws Exception { + doReturn(s3Client).when(cloudianHyperStoreObjectStoreDriverImpl).getS3ClientByBucketAndStore(any(), anyLong()); + BucketTO bucket = mock(BucketTO.class); + when(bucket.getName()).thenReturn(TEST_BUCKET_NAME); + ArgumentCaptor arg = ArgumentCaptor.forClass(SetBucketVersioningConfigurationRequest.class); + + boolean unSet = cloudianHyperStoreObjectStoreDriverImpl.deleteBucketVersioning(bucket, TEST_STORE_ID); + assertTrue(unSet); + verify(s3Client, times(1)).setBucketVersioningConfiguration(arg.capture()); + SetBucketVersioningConfigurationRequest request = arg.getValue(); + assertEquals(TEST_BUCKET_NAME, request.getBucketName()); + assertEquals("Suspended", request.getVersioningConfiguration().getStatus()); + } + + @Test + public void testGetCloudianClientByStoreId() { + try (MockedStatic mockStatic = Mockito.mockStatic(CloudianHyperStoreUtil.class)) { + mockStatic.when(() -> CloudianHyperStoreUtil.getCloudianClient(TEST_ADMIN_URL, TEST_ADMIN_USER_NAME, TEST_ADMIN_PASSWORD, true)).thenReturn(cloudianClient); + CloudianClient actualCC = cloudianHyperStoreObjectStoreDriverImpl.getCloudianClientByStoreId(TEST_STORE_ID); + assertNotNull(actualCC); + assertEquals(cloudianClient, actualCC); + } + } + + @Test + public void testGetS3ClientByBucketAndStore() { + // Prepare Buckets the store knows about. + BucketVO b1 = new BucketVO(TEST_ACCOUNT_ID, 1L, TEST_STORE_ID, "b1", null, false, false, false, null); + BucketVO b2 = new BucketVO(TEST_ACCOUNT_ID, 1L, TEST_STORE_ID, "b2", null, false, false, false, null); + BucketVO b3 = new BucketVO(TEST_ACCOUNT_ID, 2L, TEST_STORE_ID, TEST_BUCKET_NAME, null, false, false, false, null); + BucketVO b4 = new BucketVO(TEST_ACCOUNT_ID, 2L, TEST_STORE_ID, "b4", null, false, false, false, null); + List BucketList = new ArrayList(); + BucketList.add(b1); // b1 owned by domain 1, exists + BucketList.add(b2); // b2 owned by domain 1, exists + BucketList.add(b3); // b3 owned by domain 2, exists - our TEST BUCKET + BucketList.add(b4); // b4 owned by domain 2, exists + when(bucketDao.listByObjectStoreId(TEST_STORE_ID)).thenReturn(BucketList); + + AccountDetailVO accessKeyDetail = mock(AccountDetailVO.class); + when(accessKeyDetail.getValue()).thenReturn(TEST_ROOT_AK); + when(accountDetailsDao.findDetail(TEST_ACCOUNT_ID, CloudianHyperStoreUtil.KEY_ROOT_ACCESS_KEY)).thenReturn(accessKeyDetail); + AccountDetailVO secretKeyDetail = mock(AccountDetailVO.class); + when(secretKeyDetail.getValue()).thenReturn(TEST_ROOT_SK); + when(accountDetailsDao.findDetail(TEST_ACCOUNT_ID, CloudianHyperStoreUtil.KEY_ROOT_SECRET_KEY)).thenReturn(secretKeyDetail); + + BucketTO bucket = mock(BucketTO.class); + when(bucket.getName()).thenReturn(TEST_BUCKET_NAME); + + try (MockedStatic mockStatic = Mockito.mockStatic(CloudianHyperStoreUtil.class)) { + mockStatic.when(() -> CloudianHyperStoreUtil.getS3Client(TEST_S3_URL, TEST_ROOT_AK, TEST_ROOT_SK)).thenReturn(s3Client); + AmazonS3 actualS3Client = cloudianHyperStoreObjectStoreDriverImpl.getS3ClientByBucketAndStore(bucket, TEST_STORE_ID); + assertNotNull(actualS3Client); + assertEquals(s3Client, actualS3Client); + } + } + + @Test + public void testGetS3ClientByBucketAndStoreNoMatch() { + // Prepare Buckets the store knows about. + BucketVO b1 = new BucketVO(TEST_ACCOUNT_ID, 1L, TEST_STORE_ID, "b1", null, false, false, false, null); + List BucketList = new ArrayList(); + BucketList.add(b1); // b1 owned by domain 1, exists + when(bucketDao.listByObjectStoreId(TEST_STORE_ID)).thenReturn(BucketList); + + // The test bucket name won't match anything prepared above + BucketTO bucket = mock(BucketTO.class); + when(bucket.getName()).thenReturn(TEST_BUCKET_NAME); + + CloudRuntimeException thrown = assertThrows(CloudRuntimeException.class, () -> cloudianHyperStoreObjectStoreDriverImpl.getS3ClientByBucketAndStore(bucket, TEST_STORE_ID)); + assertTrue(thrown.getMessage().contains("not found")); + } +} diff --git a/plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/lifecycle/CloudianHyperStoreObjectStoreLifeCycleImplTest.java b/plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/lifecycle/CloudianHyperStoreObjectStoreLifeCycleImplTest.java new file mode 100644 index 000000000000..fe6dc16f3fc5 --- /dev/null +++ b/plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/lifecycle/CloudianHyperStoreObjectStoreLifeCycleImplTest.java @@ -0,0 +1,231 @@ +// 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. +// SPDX-License-Identifier: Apache-2.0 +package org.apache.cloudstack.storage.datastore.lifecycle; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.MalformedURLException; +import java.util.HashMap; +import java.util.Map; + +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.cloudian.client.CloudianClient; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; +import org.apache.cloudstack.storage.datastore.db.ObjectStoreVO; +import org.apache.cloudstack.storage.datastore.util.CloudianHyperStoreUtil; +import org.apache.cloudstack.storage.object.ObjectStoreEntity; +import org.apache.cloudstack.storage.object.datastore.ObjectStoreHelper; +import org.apache.cloudstack.storage.object.datastore.ObjectStoreProviderManager; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import com.amazonaws.AmazonServiceException; +import com.amazonaws.services.identitymanagement.AmazonIdentityManagement; +import com.amazonaws.services.s3.AmazonS3; +import com.cloud.utils.exception.CloudRuntimeException; + + +@RunWith(MockitoJUnitRunner.class) +public class CloudianHyperStoreObjectStoreLifeCycleImplTest { + + @Spy + CloudianHyperStoreObjectStoreLifeCycleImpl cloudianHyperStoreObjectStoreLifeCycleImpl = new CloudianHyperStoreObjectStoreLifeCycleImpl(); + + @Mock + CloudianClient cloudianClient; + @Mock + AmazonS3 s3Client; + @Mock + AmazonIdentityManagement iamClient; + @Mock + ObjectStoreHelper objectStoreHelper; + @Mock + ObjectStoreProviderManager objectStoreMgr; + @Mock + ObjectStoreVO objectStoreVo; + @Mock + ObjectStoreEntity objectStoreEntity; + + static String TEST_STORE_NAME = "testStore"; + static String TEST_ADMIN_URL = "https://admin-service:19443"; + static String TEST_PROVIDER_NAME = "Cloudian HyperStore"; + static String TEST_ADMIN_USERNAME = "test_admin"; + static String TEST_ADMIN_PASSWORD = "test_pass"; + static String TEST_VALIDATE_SSL = "false"; + static String TEST_S3_URL = "https://s3-endpoint"; + static String TEST_IAM_URL = "https://iam-endpoint"; + + Map guiDetailMap; + Map guiDataStoreMap; + + MockedStatic mockStatic; + + private AutoCloseable closeable; + + @Before + public void setUp() { + closeable = MockitoAnnotations.openMocks(this); + + mockStatic = Mockito.mockStatic(CloudianHyperStoreUtil.class); + + cloudianHyperStoreObjectStoreLifeCycleImpl.objectStoreHelper = objectStoreHelper; + cloudianHyperStoreObjectStoreLifeCycleImpl.objectStoreMgr = objectStoreMgr; + + guiDetailMap = new HashMap(); + guiDetailMap.put("accesskey", TEST_ADMIN_USERNAME); + guiDetailMap.put("secretkey", TEST_ADMIN_PASSWORD); + guiDetailMap.put("validateSSL", TEST_VALIDATE_SSL); + guiDetailMap.put("s3Url", TEST_S3_URL); + guiDetailMap.put("iamUrl", TEST_IAM_URL); + guiDataStoreMap = new HashMap(); + guiDataStoreMap.put("name", TEST_STORE_NAME); + guiDataStoreMap.put("url", TEST_ADMIN_URL); + guiDataStoreMap.put("providerName", TEST_PROVIDER_NAME); + guiDataStoreMap.put("details", guiDetailMap); + } + + @After + public void tearDown() throws Exception { + mockStatic.close(); + closeable.close(); + } + + @Test + public void testInitializeValidation() { + mockStatic.when(() -> CloudianHyperStoreUtil.getCloudianClient(anyString(), anyString(), anyString(), anyBoolean())).thenReturn(cloudianClient); + mockStatic.when(() -> CloudianHyperStoreUtil.getS3Client(anyString(), anyString(), anyString())).thenReturn(s3Client); + mockStatic.when(() -> CloudianHyperStoreUtil.getIAMClient(anyString(), anyString(), anyString())).thenReturn(iamClient); + // Ensure real validation methods are called (as everything was mocked). These ones we need. + mockStatic.when(() -> CloudianHyperStoreUtil.validateS3Url(anyString())).thenCallRealMethod(); + mockStatic.when(() -> CloudianHyperStoreUtil.validateIAMUrl(anyString())).thenCallRealMethod(); + + // Admin, S3 and IAM will be invoked to validate the urls/connectivity + when(cloudianClient.getServerVersion()).thenReturn("Test Version"); + // S3 and IAM validation is done with an unknown key. + AmazonServiceException ase = new AmazonServiceException("Test Amazon Service Exception"); + ase.setErrorCode("InvalidAccessKeyId"); + when(s3Client.listBuckets()).thenThrow(ase); + when(iamClient.listAccessKeys()).thenThrow(ase); + + when(objectStoreVo.getId()).thenReturn(99L); + when(objectStoreHelper.createObjectStore(anyMap(), anyMap())).thenReturn(objectStoreVo); + when(objectStoreMgr.getObjectStore(anyLong())).thenReturn(objectStoreEntity); + + // Test initialization + DataStore ds = cloudianHyperStoreObjectStoreLifeCycleImpl.initialize(guiDataStoreMap); + assertNotNull(ds); + + // Verify everything was called to test the connections + verify(cloudianClient, times(1)).getServerVersion(); + verify(s3Client, times(1)).listBuckets(); + verify(iamClient, times(1)).listAccessKeys(); + + // Validate the store details were propagated correctly. + ArgumentCaptor> paramsArg = ArgumentCaptor.forClass((Class>) (Class) Map.class); + ArgumentCaptor> detailsArg = ArgumentCaptor.forClass((Class>) (Class) Map.class); + verify(objectStoreHelper, times(1)).createObjectStore(paramsArg.capture(), detailsArg.capture()); + Map updatedParams = paramsArg.getValue(); + assertEquals(3, updatedParams.size()); + assertEquals(TEST_STORE_NAME, updatedParams.get(CloudianHyperStoreUtil.STORE_KEY_NAME)); + assertEquals(TEST_ADMIN_URL, updatedParams.get(CloudianHyperStoreUtil.STORE_KEY_URL)); + assertEquals(TEST_PROVIDER_NAME, updatedParams.get(CloudianHyperStoreUtil.STORE_KEY_PROVIDER_NAME)); + Map updatedDetails = detailsArg.getValue(); + assertEquals(5, updatedDetails.size()); + assertEquals(TEST_ADMIN_USERNAME, updatedDetails.get(CloudianHyperStoreUtil.STORE_DETAILS_KEY_USER_NAME)); + assertEquals(TEST_ADMIN_PASSWORD, updatedDetails.get(CloudianHyperStoreUtil.STORE_DETAILS_KEY_PASSWORD)); + assertEquals(TEST_VALIDATE_SSL, updatedDetails.get(CloudianHyperStoreUtil.STORE_DETAILS_KEY_VALIDATE_SSL)); + assertEquals(TEST_S3_URL, updatedDetails.get(CloudianHyperStoreUtil.STORE_DETAILS_KEY_S3_URL)); + assertEquals(TEST_IAM_URL, updatedDetails.get(CloudianHyperStoreUtil.STORE_DETAILS_KEY_IAM_URL)); + } + + @Test + public void testInitializeEmptyMap() { + // Pass an empty configuration map. No URL, name, details etc. + guiDataStoreMap.clear(); + + // Test initialization - should complain about providerName not matching + CloudRuntimeException thrown = assertThrows(CloudRuntimeException.class, () -> cloudianHyperStoreObjectStoreLifeCycleImpl.initialize(guiDataStoreMap)); + assertTrue(thrown.getMessage().contains("providerName")); + } + + @Test + public void testInitializeUnexpectedProviderName() { + // Use a bad provider name + guiDataStoreMap.replace("providerName", "bad provider name"); + + // Test initialization - should complain about providerName not matching + CloudRuntimeException thrown = assertThrows(CloudRuntimeException.class, () -> cloudianHyperStoreObjectStoreLifeCycleImpl.initialize(guiDataStoreMap)); + assertTrue(thrown.getMessage().contains("Unexpected providerName")); + } + + @Test + public void testInitializeMissingDetails() { + // Don't pass in the details map + guiDataStoreMap.remove("details"); + + // Test initialization - should complain about details + CloudRuntimeException thrown = assertThrows(CloudRuntimeException.class, () -> cloudianHyperStoreObjectStoreLifeCycleImpl.initialize(guiDataStoreMap)); + assertTrue(thrown.getMessage().contains("details")); + } + + @Test + public void testInitializeBadURL() { + // Admin connectivity is done first. As everything in Util is mocked, this time we use real implementation. + mockStatic.when(() -> CloudianHyperStoreUtil.getCloudianClient(anyString(), anyString(), anyString(), anyBoolean())).thenCallRealMethod(); + + // Override the URL for this test + guiDataStoreMap.put("url", "bad_url"); + + // Test initialization + CloudRuntimeException thrown = assertThrows(CloudRuntimeException.class, () -> cloudianHyperStoreObjectStoreLifeCycleImpl.initialize(guiDataStoreMap)); + assertEquals(MalformedURLException.class, thrown.getCause().getClass()); + } + + @Test + public void testInitializeBadCredentials() { + // Admin connectivity is done first. + mockStatic.when(() -> CloudianHyperStoreUtil.getCloudianClient(anyString(), anyString(), anyString(), anyBoolean())).thenReturn(cloudianClient); + ServerApiException sae = new ServerApiException(ApiErrorCode.UNAUTHORIZED, "bad credentials"); + when(cloudianClient.getServerVersion()).thenThrow(sae); + + // Test initialization + ServerApiException thrown = assertThrows(ServerApiException.class, () -> cloudianHyperStoreObjectStoreLifeCycleImpl.initialize(guiDataStoreMap)); + assertEquals(ApiErrorCode.UNAUTHORIZED, thrown.getErrorCode()); + verify(cloudianClient, times(1)).getServerVersion(); + } +} diff --git a/plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/provider/CloudianHyperStoreObjectStoreProviderImplTest.java b/plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/provider/CloudianHyperStoreObjectStoreProviderImplTest.java new file mode 100644 index 000000000000..9e48f3d418de --- /dev/null +++ b/plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/provider/CloudianHyperStoreObjectStoreProviderImplTest.java @@ -0,0 +1,59 @@ +// 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. +// SPDX-License-Identifier: Apache-2.0 +package org.apache.cloudstack.storage.datastore.provider; + +import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreProvider.DataStoreProviderType; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockitoAnnotations; + +import java.util.Set; + +import static org.junit.Assert.assertEquals; + +public class CloudianHyperStoreObjectStoreProviderImplTest { + + private CloudianHyperStoreObjectStoreProviderImpl cloudianHyperStoreObjectStoreProviderImpl; + + private AutoCloseable closeable; + + @Before + public void setUp() { + closeable = MockitoAnnotations.openMocks(this); + cloudianHyperStoreObjectStoreProviderImpl = new CloudianHyperStoreObjectStoreProviderImpl(); + } + + @After + public void tearDown() throws Exception { + closeable.close(); + } + + @Test + public void testGetName() { + String actualName = cloudianHyperStoreObjectStoreProviderImpl.getName(); + assertEquals("Cloudian HyperStore", actualName); + } + + @Test + public void testGetTypes() { + Set types = cloudianHyperStoreObjectStoreProviderImpl.getTypes(); + assertEquals(1, types.size()); + assertEquals("OBJECT", types.toArray()[0].toString()); + } +} diff --git a/plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/util/CloudianHyperStoreUtilTest.java b/plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/util/CloudianHyperStoreUtilTest.java new file mode 100644 index 000000000000..c83d5d7fd2f5 --- /dev/null +++ b/plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/util/CloudianHyperStoreUtilTest.java @@ -0,0 +1,227 @@ +// 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. +// SPDX-License-Identifier: Apache-2.0 +package org.apache.cloudstack.storage.datastore.util; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; + +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.cloudian.client.CloudianClient; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import com.cloud.utils.exception.CloudRuntimeException; +import com.github.tomakehurst.wiremock.junit.WireMockRule; + +public class CloudianHyperStoreUtilTest { + private final int port = 18081; + + @Rule + public WireMockRule wireMockRule = new WireMockRule(port); + + private AutoCloseable closeable; + + @Before + public void setUp() { + closeable = MockitoAnnotations.openMocks(this); + } + + @After + public void tearDown() throws Exception { + closeable.close(); + } + + @Test + public void testGetCloudianClientBadUrl() { + String url = "bad://bad-url"; + CloudRuntimeException thrown = assertThrows(CloudRuntimeException.class, () -> CloudianHyperStoreUtil.getCloudianClient(url, "", "", false)); + assertNotNull(thrown); + assertTrue(thrown.getMessage().contains("unknown protocol")); + } + + @Test + public void testGetCloudianClient() { + String url = "https://localhost:98765"; + CloudianClient cc = CloudianHyperStoreUtil.getCloudianClient(url, "", "", false); + assertNotNull(cc); + } + + @Test + public void testGetCloudianClientNoPort() { + String url = "https://localhost"; + CloudianClient cc = CloudianHyperStoreUtil.getCloudianClient(url, "", "", false); + assertNotNull(cc); + } + + @Test + public void testGetCloudianClientToGetServerVersion() { + final String expect = "8.1 Compiled: 2023-11-11 16:30"; + wireMockRule.stubFor(get(urlEqualTo("/system/version")) + .willReturn(aResponse() + .withStatus(200) + .withBody(expect))); + + // Get a connection and try using it + String url = String.format("http://localhost:%d", port); + CloudianClient cc = CloudianHyperStoreUtil.getCloudianClient(url, "u", "p", false); + String version = cc.getServerVersion(); + assertEquals(expect, version); + } + + @Test + public void testGetCloudianClientShortenedTimeout() { + // Response delayed 3 seconds. We should never get it. + final String expect = "8.1 Compiled: 2023-11-11 16:30"; + wireMockRule.stubFor(get(urlEqualTo("/system/version")) + .willReturn(aResponse() + .withStatus(200) + .withFixedDelay(3000) // 3 second delay. + .withBody(expect))); + + try (MockedStatic mockStatic = Mockito.mockStatic(CloudianHyperStoreUtil.class)) { + // Force a shorter 1 second timeout for testing so as not to hold up unit tests. + mockStatic.when(() -> CloudianHyperStoreUtil.getAdminTimeoutSeconds()).thenReturn(1); + mockStatic.when(() -> CloudianHyperStoreUtil.getCloudianClient(anyString(), anyString(), anyString(), anyBoolean())).thenCallRealMethod(); + + // Get a connection and try using it but it should timeout + String url = String.format("http://localhost:%d", port); + CloudianClient cc = CloudianHyperStoreUtil.getCloudianClient(url, "u", "p", false); + long before = System.currentTimeMillis(); + ServerApiException thrown = assertThrows(ServerApiException.class, () -> cc.getServerVersion()); + long after = System.currentTimeMillis(); + assertNotNull(thrown); + assertEquals(ApiErrorCode.RESOURCE_UNAVAILABLE_ERROR, thrown.getErrorCode()); + assertTrue((after - before) >= 1000); // should timeout after 1 second. + } + } + + @Test + public void testValidateS3UrlGood() { + // Mock an AWS S3 invalid access key response. + StringBuilder ERR_XML = new StringBuilder("\n"); + ERR_XML.append("\n"); + ERR_XML.append(" InvalidAccessKeyId\n"); + ERR_XML.append(" The AWS Access Key Id you provided does not exist in our records.\n"); + ERR_XML.append(" unknown\n"); + ERR_XML.append(" 12345=\n"); + ERR_XML.append("\n"); + + wireMockRule.stubFor(get(urlEqualTo("/")) + .willReturn(aResponse() + .withStatus(403) + .withHeader("content-type", "application/xml") + .withBody(ERR_XML.toString()))); + + // Test: validates the AmazonS3 client returned by CloudianHyperStoreUtil.getS3Client() + // which is called indirectly via the validateS3Url() method can connect to the + // remote port and handles the access key error as the expected s3 response. + String url = String.format("http://localhost:%d", port); + CloudianHyperStoreUtil.validateS3Url(url); + } + + @Test + public void testValidateS3UrlBadRequest() { + wireMockRule.stubFor(get(urlEqualTo("/")) + .willReturn(aResponse() + .withStatus(400) + .withHeader("content-type", "text/html") + .withBody("400 Bad Request"))); + + String url = String.format("http://localhost:%d", port); + CloudRuntimeException thrown = assertThrows(CloudRuntimeException.class, () -> CloudianHyperStoreUtil.validateS3Url(url)); + assertNotNull(thrown); + } + + @Test + public void testValidateIAMUrlGoodInvalidClientTokenId() { + // Mock an AWS IAM invalid access key response. + StringBuilder ERR_XML = new StringBuilder(); + ERR_XML.append("\n"); + ERR_XML.append(" \n"); + ERR_XML.append(" Sender\n"); + ERR_XML.append(" InvalidClientTokenId\n"); + ERR_XML.append(" The security token included in the request is invalid.\n"); + ERR_XML.append(" \n"); + ERR_XML.append(" a2c47f7e-0196-4b45-af18-a9e99e4d9ed5\n"); + ERR_XML.append("\n"); + + wireMockRule.stubFor(post(urlEqualTo("/")) + .willReturn(aResponse() + .withStatus(403) + .withHeader("content-type", "text/xml") + .withBody(ERR_XML.toString()))); + + // Test: validates the AmazonIdentityManagement client returned by CloudianHyperStoreUtil.getIAMClient() + // which is called indirectly via the validateIAMUrl() method can connect to the + // remote port and handles the access key error as the expected s3 response. + String url = String.format("http://localhost:%d", port); + CloudianHyperStoreUtil.validateIAMUrl(url); + } + + @Test + public void testValidateIAMUrlGoodInvalidAccessKeyId() { + // Mock HyperStore IAM invalid access key current response. + StringBuilder ERR_XML = new StringBuilder("\n"); + ERR_XML.append("\n"); + ERR_XML.append(" \n"); + ERR_XML.append(" InvalidAccessKeyId\n"); + ERR_XML.append(" The Access Key Id you provided does not exist in our records.\n"); + ERR_XML.append(" \n"); + ERR_XML.append(" a2c47f7e-0196-4b45-af18-a9e99e4d9ed5\n"); + ERR_XML.append("\n"); + + wireMockRule.stubFor(post(urlEqualTo("/")) + .willReturn(aResponse() + .withStatus(403) + .withHeader("content-type", "application/xml;charset=UTF-8") + .withBody(ERR_XML.toString()))); + + // Test: validates the AmazonIdentityManagement client returned by CloudianHyperStoreUtil.getIAMClient() + // which is called indirectly via the validateIAMUrl() method can connect to the + // remote port and handles the access key error as the expected s3 response. + String url = String.format("http://localhost:%d", port); + CloudianHyperStoreUtil.validateIAMUrl(url); + } + + @Test + public void testValidateIAMUrlBadRequest() { + wireMockRule.stubFor(post(urlEqualTo("/")) + .willReturn(aResponse() + .withStatus(400) + .withHeader("content-type", "text/html") + .withBody("400 Bad Request"))); + + String url = String.format("http://localhost:%d", port); + CloudRuntimeException thrown = assertThrows(CloudRuntimeException.class, () -> CloudianHyperStoreUtil.validateIAMUrl(url)); + assertNotNull(thrown); + } +} diff --git a/pom.xml b/pom.xml index 7168e3e8830b..ad07b9c99bd1 100644 --- a/pom.xml +++ b/pom.xml @@ -295,6 +295,11 @@ aws-java-sdk-core ${cs.aws.sdk.version} + + com.amazonaws + aws-java-sdk-iam + ${cs.aws.sdk.version} + com.amazonaws aws-java-sdk-s3 diff --git a/server/src/main/java/org/apache/cloudstack/storage/object/BucketApiServiceImpl.java b/server/src/main/java/org/apache/cloudstack/storage/object/BucketApiServiceImpl.java index ca0e6291e529..ea3361507ca7 100644 --- a/server/src/main/java/org/apache/cloudstack/storage/object/BucketApiServiceImpl.java +++ b/server/src/main/java/org/apache/cloudstack/storage/object/BucketApiServiceImpl.java @@ -303,8 +303,15 @@ protected void runInContext() { try { List objectStores = _objectStoreDao.listObjectStores(); for(ObjectStoreVO objectStoreVO: objectStores) { + logger.debug("Getting bucket usage for Object Store \"{}\"", objectStoreVO.getName()); ObjectStoreEntity objectStore = (ObjectStoreEntity)_dataStoreMgr.getDataStore(objectStoreVO.getId(), DataStoreRole.Object); - Map bucketSizes = objectStore.getAllBucketsUsage(); + Map bucketSizes; + try { + bucketSizes = objectStore.getAllBucketsUsage(); + } catch (CloudRuntimeException e) { + logger.error(String.format("Failed to get bucket usage for Object Store \"%s\". Skipping this store.", objectStoreVO.getName()), e); + continue; + } List buckets = _bucketDao.listByObjectStoreId(objectStoreVO.getId()); for(BucketVO bucket : buckets) { Long size = bucketSizes.get(bucket.getName()); diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 7b7b98071652..d3cbc2dd7344 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -520,6 +520,11 @@ "label.clientid": "Provider Client ID", "label.close": "Close", "label.cloud.managed": "CloudManaged", +"label.cloudian.admin.password": "Admin Service Password", +"label.cloudian.admin.url": "Admin Service Endpoint URL", +"label.cloudian.admin.username": "Admin Service Username", +"label.cloudian.iam.url": "IAM Service Endpoint URL", +"label.cloudian.s3.url": "S3 Service Endpoint URL", "label.cloudian.storage": "Cloudian storage", "label.cluster": "Cluster", "label.cluster.name": "Cluster name", diff --git a/ui/src/components/view/ObjectStoreBrowser.vue b/ui/src/components/view/ObjectStoreBrowser.vue index 531846a9da57..9f94cc619c7b 100644 --- a/ui/src/components/view/ObjectStoreBrowser.vue +++ b/ui/src/components/view/ObjectStoreBrowser.vue @@ -468,7 +468,8 @@ export default { return false }, uploadFiles () { - if (!this.uploadDirectory.endsWith('/')) { + this.uploadDirectory = this.uploadDirectory.trim() + if (this.uploadDirectory.length !== 0 && !this.uploadDirectory.endsWith('/')) { this.uploadDirectory = this.uploadDirectory + '/' } var promises = [] diff --git a/ui/src/views/infra/AddObjectStorage.vue b/ui/src/views/infra/AddObjectStorage.vue index 94c4ef0adb98..38c2e7661971 100644 --- a/ui/src/views/infra/AddObjectStorage.vue +++ b/ui/src/views/infra/AddObjectStorage.vue @@ -44,15 +44,44 @@ >{{ prov }} - - - - - - - - - + +
+ + + + + + Validate SSL Certificate + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + +
+
{{ $t('label.cancel') }} {{ $t('label.ok') }} @@ -82,7 +111,7 @@ export default { inject: ['parentFetchData'], data () { return { - providers: ['MinIO', 'Ceph', 'Simulator'], + providers: ['MinIO', 'Ceph', 'Cloudian HyperStore', 'Simulator'], zones: [], loading: false } @@ -95,7 +124,8 @@ export default { initForm () { this.formRef = ref() this.form = reactive({ - provider: 'MinIO' + provider: 'MinIO', + validateSSL: true }) this.rules = reactive({ url: [{ required: true, message: this.$t('label.required') }], @@ -128,6 +158,15 @@ export default { 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.loading = true try {