From b43c6281065fe844764a5d4b1ff45e4320b27cde Mon Sep 17 00:00:00 2001 From: Thomas O'Dowd Date: Wed, 25 Sep 2024 01:54:05 +0000 Subject: [PATCH 01/14] Updated CloudianClient (Cloudian Admin API) client connection code - Added API to return the version of the HyperStore service - Added API to manage user Root credentials - Added API to return usage information for buckets owned by a group Fixes: - Only disable https certificate validation if using https - Don't log the admin password on error - Update to always throw exception on error instead of sometimes returning empty data. - Fixed empty list cases for list users and list groups - Use an easier to understand exception message for SSL errors - Updated test cases --- .../cloudian/client/CloudianClient.java | 288 +++++++++++-- .../cloudian/client/CloudianCredential.java | 88 ++++ .../client/CloudianUserBucketUsage.java | 106 +++++ .../cloudian/CloudianClientTest.java | 383 ++++++++++++++++-- .../cloudian/CloudianUtilsTest.java | 2 +- 5 files changed, 798 insertions(+), 69 deletions(-) create mode 100644 plugins/integrations/cloudian/src/main/java/org/apache/cloudstack/cloudian/client/CloudianCredential.java create mode 100644 plugins/integrations/cloudian/src/main/java/org/apache/cloudstack/cloudian/client/CloudianUserBucketUsage.java 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..0025bca63000 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,7 @@ package org.apache.cloudstack.cloudian.client; import java.io.IOException; +import java.io.PushbackInputStream; import java.net.SocketTimeoutException; import java.security.KeyManagementException; import java.security.KeyStoreException; @@ -28,11 +29,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 +59,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 +92,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 +112,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 +129,30 @@ 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); } } @@ -159,22 +180,108 @@ 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)) { + return new ArrayList<>(); + } + 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)) { + // Assume userId is also set (or request fails). + 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 +292,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 +312,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 +338,25 @@ 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"); } + // The empty list case is badly behaved and returns as 200 with an empty body. + // We'll try detect this by reading the first byte and then putting it back if required. + PushbackInputStream iStream = new PushbackInputStream(response.getEntity().getContent()); + int firstByte=iStream.read(); + if (firstByte == -1) { + return new ArrayList<>(); // EOF => empty list + } + // unread that first byte and process the JSON + iStream.unread(firstByte); 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 +369,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 +384,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 +473,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 +492,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 +509,25 @@ 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"); + } + // The empty list case is badly behaved and returns as 200 with an empty body. + // We'll try detect this by reading the first byte and then putting it back if required. + PushbackInputStream iStream = new PushbackInputStream(response.getEntity().getContent()); + int firstByte=iStream.read(); + if (firstByte == -1) { + return new ArrayList<>(); // EOF => empty list } + // unread that first byte and process the JSON + iStream.unread(firstByte); 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 +540,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,8 +555,8 @@ 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; } -} +} \ No newline at end of file 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..9efa395c2547 --- /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; + } +} \ No newline at end of file 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 index fc9a54d1ba62..2aa30526f0f5 100644 --- 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 @@ -32,12 +32,16 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import java.util.ArrayList; import java.util.List; 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.junit.Assert; import org.junit.Before; import org.junit.Rule; @@ -115,6 +119,197 @@ public void testBasicAuthFailure() { client.listUser("someUserId", "somegGroupId"); } + ///////////////////////////////////////////////////// + //////////////// 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 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 ///////////////////// ///////////////////////////////////////////////////// @@ -163,16 +358,27 @@ public void listUserAccount() { } @Test - public void listUserAccountFail() { + 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"; @@ -188,31 +394,26 @@ public void listUserAccounts() { } @Test - public void testEmptyListUsersResponse() { + 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") - .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")); + Assert.assertEquals(0, client.listUsers("someGroup").size()); } - @Test + @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(""))); - final List users = client.listUsers("xyz"); - Assert.assertEquals(users.size(), 0); + client.listUsers("xyz"); } @Test @@ -265,6 +466,125 @@ public void removeUserAccountFail() { 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 ///////////////////// ////////////////////////////////////////////////////// @@ -310,14 +630,24 @@ public void listGroup() { } @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(""))); - final CloudianGroup group = client.listGroup("xyz"); - Assert.assertNull(group); + client.listGroup("xyz"); } @Test @@ -335,33 +665,24 @@ public void listGroups() { } @Test - public void listGroupsFail() { + public void listGroupsEmptyList() { wireMockRule.stubFor(get(urlEqualTo("/group/list")) .willReturn(aResponse() .withHeader("content-type", "application/json") .withBody(""))); final List groups = client.listGroups(); - Assert.assertEquals(groups.size(), 0); + Assert.assertEquals(0, groups.size()); } - @Test - public void testEmptyListGroupResponse() { + @Test(expected = ServerApiException.class) + public void listGroupsBad204Response() { wireMockRule.stubFor(get(urlEqualTo("/group/list")) .willReturn(aResponse() .withHeader("content-type", "application/json") - .withStatus(204) + .withStatus(204) // bad response. should never be 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")); + client.listGroups(); } @Test 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 From c2a883cb0a91ec975df28b15e4b1e0b507ab0213 Mon Sep 17 00:00:00 2001 From: Thomas O'Dowd Date: Wed, 25 Sep 2024 02:50:23 +0000 Subject: [PATCH 02/14] Fix issue where usage may not be collected if an object store is down. If there are multiple object stores configured and one of the stores is down or has some other issue returning bucket usage, it can cause usage collection to be skipped on other object stores. --- .../cloudstack/storage/object/BucketApiServiceImpl.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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()); From b1da8220093c62c6769a8fe2ce3aec2fd0500ff5 Mon Sep 17 00:00:00 2001 From: Thomas O'Dowd Date: Thu, 26 Sep 2024 00:05:42 +0000 Subject: [PATCH 03/14] Fix to avoid pre-pending a second '/' to the object name. - Previous Behaviour: if uploadDirectory was empty, it was set to '/'. When the object is uploaded the API adds another '/' between the endpoint url and the object name, so an object called 'abc.txt' would be uploaded as '/abc.txt'. The bucket listing is done using a delimiter of '/' which returns the common prefix '/' of the '/abc.txt' object and the object itself is not listed at the top level. - New Behaviour: The object is uploaded as 'abc.txt' if uploadDirectory is empty as would be expected. --- ui/src/components/view/ObjectStoreBrowser.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 = [] From b64b11b9947a821aa67a7020e7649d2165fc9b46 Mon Sep 17 00:00:00 2001 From: Thomas O'Dowd Date: Wed, 25 Sep 2024 06:22:54 +0000 Subject: [PATCH 04/14] Use a password input field type for object store secret key entry --- ui/src/views/infra/AddObjectStorage.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/views/infra/AddObjectStorage.vue b/ui/src/views/infra/AddObjectStorage.vue index 94c4ef0adb98..40150d5cd274 100644 --- a/ui/src/views/infra/AddObjectStorage.vue +++ b/ui/src/views/infra/AddObjectStorage.vue @@ -51,7 +51,7 @@ - +
{{ $t('label.cancel') }} From d0a21bcc711074918bc1387b3e3f4d2712981d84 Mon Sep 17 00:00:00 2001 From: Thomas O'Dowd Date: Wed, 25 Sep 2024 03:04:59 +0000 Subject: [PATCH 05/14] Add New Object Storage Provider Plugin for Cloudian HyperStore - Allow the CloudStack administrator to connect to Cloudian HyperStore object storage. - Once connected, CloudStack Accounts can create buckets that are managed by and belong to their own Account. - IAM Credentials are available for each bucket such that Accounts can use the buckets either from 3rd party S3 applications or from the CloudStack Bucket Browser UI Feature. - The plugin supports all the current CloudStack bucket operations such as Object Lock, Versioning, Encryption and policy settings. - The plugin currently does not support setting a bucket quota as HyperStore does not currently support that functionality. - Bucket usage is supported. More Details: - See plugins/storage/object/cloudian/README.md for details UI Changes - Add Object Storage for Cloudian HyperStore: - Cloudian HyperStore Object Storage requires more fields than Minio, Ceph and Simulator so when the Cloudian HyperStore provider is selected, the GUI adjusts and offers the extra fields that the provider requires. --- client/pom.xml | 5 + plugins/pom.xml | 1 + plugins/storage/object/cloudian/README.md | 175 ++++ .../cloudian/add_cloudian_hyperstore.png | Bin 0 -> 138188 bytes plugins/storage/object/cloudian/pom.xml | 64 ++ ...oudianHyperStoreObjectStoreDriverImpl.java | 867 ++++++++++++++++++ ...ianHyperStoreObjectStoreLifeCycleImpl.java | 156 ++++ ...dianHyperStoreObjectStoreProviderImpl.java | 87 ++ .../util/CloudianHyperStoreUtil.java | 214 +++++ .../storage-object-cloudian/module.properties | 18 + ...spring-storage-object-cloudian-context.xml | 31 + ...anHyperStoreObjectStoreDriverImplTest.java | 406 ++++++++ ...yperStoreObjectStoreLifeCycleImplTest.java | 231 +++++ ...HyperStoreObjectStoreProviderImplTest.java | 59 ++ pom.xml | 5 + ui/public/locales/en.json | 5 + ui/src/views/infra/AddObjectStorage.vue | 61 +- 17 files changed, 2374 insertions(+), 11 deletions(-) create mode 100644 plugins/storage/object/cloudian/README.md create mode 100644 plugins/storage/object/cloudian/add_cloudian_hyperstore.png create mode 100644 plugins/storage/object/cloudian/pom.xml create mode 100644 plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudianHyperStoreObjectStoreDriverImpl.java create mode 100644 plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/CloudianHyperStoreObjectStoreLifeCycleImpl.java create mode 100644 plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/provider/CloudianHyperStoreObjectStoreProviderImpl.java create mode 100644 plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/util/CloudianHyperStoreUtil.java create mode 100644 plugins/storage/object/cloudian/src/main/resources/META-INF/cloudstack/storage-object-cloudian/module.properties create mode 100644 plugins/storage/object/cloudian/src/main/resources/META-INF/cloudstack/storage-object-cloudian/spring-storage-object-cloudian-context.xml create mode 100644 plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/driver/CloudianHyperStoreObjectStoreDriverImplTest.java create mode 100644 plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/lifecycle/CloudianHyperStoreObjectStoreLifeCycleImplTest.java create mode 100644 plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/provider/CloudianHyperStoreObjectStoreProviderImplTest.java 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/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..28c24af7af3c --- /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 not documented here. + + ```shell + hsh$ hsctl config set s3.qos.bucketLevel=true + hsh$ hsctl config apply s3 + hsh$ hsctl service restart --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. + +![Add Cloudian HyperStore Object Storage](add_cloudian_hyperstore.png) + +These configuration parameters are delivered 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 note worthy. + +### Bucket Quota is Unsupported + +This operation is not supported by this plugin. Cloudian HyperStore does not currently support restricting the size of a bucket to a particular quota. + +### 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 of 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/add_cloudian_hyperstore.png b/plugins/storage/object/cloudian/add_cloudian_hyperstore.png new file mode 100644 index 0000000000000000000000000000000000000000..f281d002cbc663a66d149b6d36537dabcdf9a09d GIT binary patch literal 138188 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 z zP~N#W_yI-))^<;qGggEDyNdqzLiZyQJU*n4;NNj`_^(w@!4SWEL^$ek`DIVj)_#;l8K~2EUdm0}!6FM)} z=K{(77$gwPLf#RxFE%4e>b@p9Ja5T`;wkX6vxz@gm!@9Z`x93NmxF)wv?Vvy`wCo)j5IXnw5n`AmUsKty0R5;h=_dF(!ERU z9}oudN+}yBe&33Ao|ug6NQB-IQ)xP%yNW6~+E)N3BNC zaYh1Y+jM;`vh2;Mp;vZL{n;7*hw!%3^@~qP7zckl(;AVh+(`tFoD%AFFJ!yW& zb=WS<6lA|L@7u$*6P(=G)oC6xq3icGq%|M;uQXA4&Rqa+&I9Jjrw@5?>!+YcH+_6V zeKhalnntSkmh2@FUvISMS&{tRGi=MB+)jF3`^RfPNbmA;L-1ZWs(kGt>c{fY;<^KV z-TNl^g`TPVm0{Q_yNRyTd6e>ykPs{xjcYP-Gz3>DQdHzEo0Da=*YU{dmFW^ZlLv z%X4;F5xiK<2b15H+mg!r+FV1p-s6;EnlN4rZM;xZ%2xBK9N&E4N9ECvUO%eqwc9Cg z^G!IOXfiDQp~rTB55mSzn!Z=0P^p;Mn1 z+<0A^hOL;-XC%q;ve8MQNwIcZj-{AL*%0J6(_Wtsj6O-P-oj`Ruq?>W!GhI)Pc%f< zNC|?4^3Ih^&MC3Nl6or-D5x`K0yemzs_nSHh1c?99gqi)ke}wy?k3cJR$lN+3yizHE6a z=eZquZ$e-W$kZzEUkEn}+HK(roTq7Gtr}AEjDF&!=!nAcBJMNd)Q-!T;-bdtH|4ny z2$fIM`Z(l7l8#}I08%vP0|Udy+Ytc_sH~0gYLQGW69CcW&hWc|NbT8TS$wwxD``^d z{pI{p2v3fDTrQ`FOSD&iO`&_{Giufq6u#Mxt^YK5geFhW98m6XdnogA` z6Bk9M9I17}sYQf+-v=_pc+URNxLY^2mGaG-akaGdyyn|GshOx${pg$MJ!mNf8+8Vy zHvv>`1xI78q0cfFmp5;;;02KmA~v_rEChR%B|jQiT*l!HyxsD57P)@`vEmGT?$YnZ^pQs=(99yzrMs>bZ?xDU-nWUp)Q|n`QdDb!s$E&1CO%OkV zLDnIZ+5n07r4syLj6g;bL^s>6xjWVy$RJof0+uJ|txtP#GXD$o+*Q}y?nZ}NJ7rjY z_SuV|b<=dEO;Un!4 zXmkG7O8TSok2?mX>7sHz^(+JQ_t?Z`DQOFVk8u1BCO*$h8(4H7NP1n;r7L46P112) zzn=C(A&Xg%{kJ{%_ng1xWZ;|$WN3T@QlzF0*!jXXfOVU+1Yk}ERcK~}&W+671bd*m z7I1R@RGfM!%06|)F?bswA}bx(Qg`|V-?5Tfm(VpE zw;TYoVyEG{p_fvlnv^1doYN_Pn77roD9D`W`gcV5`)x$;;ghL~5qbt8c$HCSK#qZt!b+i|zIZ`0Oq?JXtbN&KrVDE+U4mlTW_S)O~YxaH<#ZyvZgO<4| zBuG%wNhRLYVJe9RRnPtD%?|3Q+)JL^h`h!T?NtH)A}Py8SqoKH3jj$UbQCi3A18GD z5v1UiQlv#Q@&mbSH7&ZGFB&?(=P~J*BrPryQvUhAz@$Yg0yo&C9*Y3T;bF5? zs+7rHqLPDEY-%L94&woCn>nSRt!lr9!)v6b`8QDj5v80j9$V3KJs*UGw5+ACQ6%fM z+=!C&GR!WX7?jr=jOl$tEo9Zq+|z4_BWwpbs9bwXK~Jqc;tQ~Q03s$$r8BU7`D(X- zCHP)nH_J;3!Z^P*YvZtKZ_9N2jF?;%UsbvcCS}3M8-!i;+o-eok%;ByD#XSm>&XJ= zA_w;tOvW~6P@YQcFKajJ#r3!w0fh2eB%C+r;41fg+^44}g18I2{|n>r5Y2^i0xs-( z8Z*&1;k{b1Sc+8kc1kZ(u=LP3fWFc+QTJzRb!B|pTtM%IpmtZ5;z~6n!&8s0x24O- zFDzs!g4Of3;C_g4$n82Ikcdvp8~;@E%~P`Y3A#}#h*c>wf-M(3pF4@)z9ZQ{bbr)h z_@*=W{LpEyL~2*4zMELHSkkbLg&}_hr=tnZz>!d; z%`d^!S1|4o*M8ba$ZTS^zQV1#3&&B|WtDn6uph%g&J!{?)oTHHJ!|6oY}-4(ky@%h+o4E9SBanu678-~wwKF2$TAAwyFdBUB%aZ}5}+MLwL!-NmEt!Xqw zfX1T2?=zDIbtH|A`#R3 z65o=Wa8}H4?aDqVk^SEk;J-&spC>B6kwsFBco63{^zQ}%k1xz?3bT%TxFUO-{2i|! z91JFq{*@U9e0UEhr{uu8);IZPjH+2`A2?V%TWTF=^|KExIPa-Xo{{L!X!^tw15UUsD zJokV?ymhWCR$NpPbR#`-F*>InP=soZoRB3T&C+!LGj#UW_e>IcS4G4{JpLaP)y?rXA;qBot4Z z^vc-3E@*vg+*a}c#=R9XZ!&u!$TovPb6u12o~+W#wX!cx@R@_`b4XeRK2X~BrDCxp zobo1pm`}bg0H6`Ug6c zWH5PNzrcP2ipuY9$!r{_E$wl!FAzneOk7gF3=frw4Yq|6wnFiEX~*2EM_^jE?^vgr+-LixXN8Ri`i8}>!k$iV7Dodk^J)lMyXknoRoBsFW@$ZvE^d+WCWglt;03VH` zDnSEiDc3~BL-TD>v7y-Z61%)Vu~K z#Isz*aMX1ezeexZ>i*_Qy`zz7H$Olm=KH89g6sBPsZu9UW z(tyh;-g3U@9t?nU@+NEjVx|eu%!=PUpm7AbO-Aq?R|my(2wkLwo?Bk3AVR4k+WP12 z;Ora>8~rVat^7nNz-vDmm(^o`pe|#abo@JrTmrgc89Nvvsf73yc!o9Dc=muJ4V@R> zijE2Za=bpL85{9)Pe{~>?+n25plI5A^^N$w$Z}p*ix@VWhBtMAK+U9^KHpm9 z;GONJAMfphh_!SwI&zvoWn}`wx`=r#J;pIHb_RyJg54g*_(gzfm2E`WduOdT8vhX;Tu@+1(F#9a zqo5IdmWV;eORsNTT-5@1w2}(N@*M;39t3EoaM1beYe<{s>sF>3c*jM?mb0dhZmW_0gxmB(k=$A2eq|u`9xqKsS)2haAM=CjJr1tm!&b;ZG7zYpEDG)k&oxz9KajC%go1$d2q|V+#Anpb5L~AA_~I`aGKs)g z8@_TQ1$}LE&xa?BJf;IZn7Z1Zy^HmzkLHSAIKEP87nhuLU)DU5Hi<`WK*7o8XJpMt z`1KED(7{;pT0V@YyT9$Gk%FM1OkPU2bE8e=cjI3_wcjlP?zBCGi8o)q!xTysFhhUt zlQnpoZwjof6rvA{VkzGI+*mv>yqV&dF31bZy4^ObsR;nOGjGnFFIwzq){0GV#+9wd z+$^#&VGZ}d|q~h8^>_@_(3n1UwU*}rKvrTcN zCbs#%TXb9Qm{2P-2qu-nwM6s))UpeWY~_j^F&!>Si#(ne-hS5GR@&gAsw_rGI03^n zIt|@q5hfN({MB>5dQiN!LX+{BZD!bCPZ(A(^cB7qu7X-Av@Vt}7`+=Rjj3WXpQgjD z9&ATOVt^ZPGZP)4>d<63n@e%i13$M@Iq<4>wFoQ^f7F=}hPwV5*o(q8u=^(^t_>|+ z5w@wDPcX^PPQU_9r5jFv&q_K6=k2PBsWF%jkBSt)jyHEVGG9fk3Vi;dL@P6cc>|Ia zX?o#rETQceEbQPx^;Y;{x2xV=>lvc#ZQfAejD!ebzuoHi3q%^;HkZD~v5^w^?N+3r zwF@+hw6N^aUpZffNq3Ro*47Mze<_$cBgkz3hu6SjJqThKTyuZ^^i}B^1%!KFA(rLw z!WnICA^ov` zA_MEIodLyPwW&9@fr`O{6>l%tW)SFkmo7N`u)b-Ph5LJ;-ldn8qkmmsc)Bi`$`Fv1 z8Xio+fr--_3aL+X?F+_?(BTH1;gV;Xn6ix&vZ(q1pMZ#ERYmtL_3=iq1LS_~&T}oa zj7oNOr3uu^2Od5&Av6THe-t=-+cR*(k>be}MLbXbF<1C|{zkFAPvOQ!h5PdFwP0g3`Nv!6ltc+S zcS7xQH)Z%iCOi{mTN6u7C2Dpj{&l%Kp_ne#uQ^@1Jsmq)i|)Y7saX(AMD7pH-+jKe zFnWVonS*Di4~&FX$qj%I*`&sk)O&9={*;76NC!w!u{d zDu=VzB5~eT=y4^}cJe52qdOOF9p=UJ>#@aFqJ6#6`fn&cQ6`GR%rkFdYdZY??ZCA{n<%pCln z706(4ar;WI$Ima{1JJvWmFocA{Uk<8^O)Z3O%BR9y(P+iKL@$uFqJPhe%N<>t@m9p zEYO-7#!r>8aBcc#5T#?=1(Rq=lF)-6=v3CS5+4EdMxm0@(KII2v&g6=nCgHxO!T;v z5kV~;zAy*yjPIm#XkTbBQRe0L@5YBSq^6yhrXM3}K?Kg7y%``bCWKj|kbtd7F-rv+ zr2DJrw9f)Q(9XRPFs6;9mXGl#B%GA9xG1~3X+syV4v=zK4db3JfdXbqkD=DVwy%mO zLDEcw6?N_4j3ne8Hzmw|YEwPp@L7`I6yXbLFDeP{pnZ6Gq+p){`+)Yh*p9<|<~Uus zgdQh2sv!Q$_PLFYd&j@1O{oSa784sww04OBZR1AksWg4ixfJ7dj@G;~i8S{@l9ZUb zr;|Rt$`d+tyaY4Pro3Y;#SlUS&*So!x{Wyg4i?WOT(J&t&6edw*C$R=6LXaagA@DJ zgq8Q)8{|r~86S+E?(IAAAMvk&nttVN`O++0@`H^K6^GqI0N5qF6m;0KbC=1{h{-8S z&A6kY+q@;#4)>886+GN%?N<-^7SXbRKZ*a@f~7^H`=Z7=%N-0O>3MJMD?>SxWEr;u zrs;bQTEBzh*~3umexqlk(C(O+4Kub^U0=W`VXuK^2<2V_Xt7!^-b;?QqpaDnLsp+*}~bgiJ&O9c1F0Ts*-u2C>3(H%PTztba@5D({1^-#{u&pJBo&U!8WoR-lVp6| z5ZL$rrp;xIf^G~h#3mq2z3%m!H|95!9)~fz;a^JplG}KbsVF?NZeNW=a;qR_l(cAG z_`hBNL>>39|Dd+W2t9}mxa5e+;`MpX#ChjtRPXx(IT_MkNxD|+75C-?L%|f(e1pnc zU*6~!MAI^q*Nf>k_5h`ki;=S0h2OLeH##m(w^euJ<1caev30W9U$0Bu!tc>2f#Lan zB?D9SD*cM`_gvd1exvvs>da4@If_FZm-uUbPm#Y6dO$l!CLv1ZAY3m!!uFC)6^k!{ zhW)}CXDiNi9N#P}cZ=Z2{>Vruk?^`%MU-_^%irLnOn{~^j6xFNLEoDAQMF4dMwpbX zPP7@#V(RN$VoelId)(#TRh3g1ij_xTW~@(B8P4`2b^nY^UHsMo4?$9QUH+E!mhg_I zMN0+x+XhCj`FQQjJgK^JX~K@qz)E0c%k&pK+xLiggI_-qjq{3}7Xw3t9GQU##tTxl zq%16rkSp{*XQnFh*$C$@PZpRq2DsOdH3?gBe$!S%+n1u3Vaq(aH^f^l0E0o?M$7{k zl^+8ciHQxleRao8)Sp-}6%VT(Hfm@}MOJ#pH%T2jxz*h>(M+NG9atMze&AjDPJrvU zRs^!XHlMFnvKzE`b^=+113u389*yOh>}K>0^`4QBRWFb=qC3hjS6wuy$IH;lO?HwS z)Sru8ztXv*ZE@#qabon{Wd9B#y7bDb7mjQV_sU(%ZpbEc`V1z0W+0F(y*)Ocrl7X{GdH$KoG?JUlE| zt@bXv!~9*Z@!nU9KUuirzrDSdwIdRG9aueC1@hGHs+s#eWGc|@<=X+MiD{)JZ8#gCPg z+UXV(Y1BqRr}mkiZ^0|nnJ5WiYQ+K`P;!8AH54nPEia2!Y!nibZ0uebk3vOxI}dP{*!kveEIe9$62AUo7o$DnO2E@ z*ZkzRGu*234Eft{?@34H_!4|*jYX=^`qCFtnzNct;En!vkkz&sDn??-OPGqRs%h7z zr~yiIWNM$uv|-!}$*@DkVO3s#7qe!GRjg&&>M=z4IIoV8dP?Pvs@Zhb!{YuSaSIFMl)uBc=_YAy$08*iv{h+D39VdngfbF@DF%P*W`A^Pr_A}y!>^DZxmlg=eA{xv#Ua;955H4FQyyHMTe zbiNa`g0xXp{3JusEQfPh_{}5)43ud}%>=JDe7C?+wDy3sD^qhFmI)I4$_2?lsKq!o zKGPX!VqQ5$RNJlTY7@wG*e5_&S0Vd1&4S$3&_PTwLLd1|TqBO~qbaX%lnsrbvqp8| zjAtWF;v1E5k~s6cJmCDoxD4lyyV$uu{as_eW})B#HZlxahkg(XFUG1Zg#{I7ZHpd;^d+U?6HS!&NwjQ$dqus>km9ZO$DFMir0i)o2leghvUO}DSMZ?kgcGjh zAlbVeOsyAFZ#u6z2IEl6ZY*REwNjYaJ(w(<-L0OX2buw_fB)+@;Q-zLmHefFa@Dismw>y`G*^i zK}Npeivy1|xAXX77%j?jm!)Dc^G%!-$M9!xP|lbru#%?X;AKHQE94U~Atgr0DBG^6 zW`}btY`7PLzGXHbpc}!1c64S0W54o@=scQi=`52dp$}iFfk<&xV){r-hblv<4m2$t zJz^u*dRPgc-j@)h2_@fC7Skxmg}qRYq1bhR_9jrm28tZ*M4-`Xk^Bp~J)#T3CzLKE zg!U;W(BTp#yI_;El_ zhr^`nD6t{qts7i#G@(40b8W$9BjxKE#gEjg;$}c0?OlPE6qTkI>s}ulLSiAv$4Ool zoDLwwx)5y|5njqC`0^_d_jZz7ZA(A84sU+(@SBQ)3TS;Ba6w7sYrB^&wvbQV(LAy| z5qzWNY2FVPesyRKeJnQkS0stpd9inh7kt3T+%O#{ZQ>JFFK7ViRVF?tst(i%WJD$0 zVV2*!ij8CNr~@NaAw)^_d;@Smhw9)af zcD(F&e!$hoM{{o&XQnZHe>d zi0P*2oa3!S8dbiBXS*JB#d8CT!b9}ne%O-@#stGF&A3vLlnkp8s5u2cF*U_0p2Gpf zqU~8&&pKr6Vq#udu879HExcMx6)Y2^ zGt${DHVF6+S=iRQ3%tQkTAkY~`U)J`dN1@6$`;lM_g)&Y5-N@pPkw>-DQY2dFSDlb zW}o!9!ljPzi|U>e{vIOCEG2xMYI(3$#ooPd^V0d?uIU&W{O=$Htb_|4C%tR9UBpty z8KI<~XL{x7`;1XA7y_=w6k^b^7Dv}yT7bhkdGL>q$=u!tvX*?tbQDOCHm-?AVFDUU zZmPRQ3Q`2tHu0KTp`mY{Yv%V2g-0&@@lhP(f7k4t+N=e0suumzkj{o zFfhkNaFN&W4}g^rXk*1MIDzU2wc(#_OXh)62cdl3(Cs5VpiitqQz2$+MP@}H%ALfB?6hGuz zj5|Xk(ag;F{WG!&_!T5u2{XgFc zh&|3ggec089whR8()j9>Zy{LZ%UFVoj@AowDRirW4%;cEng?peLDyR%+M8a*xXMj@HR$UH1PYzgE|GCAscX}2ZIG(02+DqmTL=onz^BH(H<2Y_0;v6^kw<2;za_5!O!J}v3iMifdu+ZXMYtFuz|$g4Ci^3a zlVZp_7Z-=Qi_%mtouowC3*Std2eTs{TYGNMVR5hdQ!N3zW(R|Chx+m8n zC@~Sz=GFtN_!ENzUw-=v5bjC-(uI}Ko38KGJR~B-eSYL|xBb?#z`d^^qc2!KJn5tWH zHz{~#j%6y`eE7i02iaj7OLt#na>1mDV_12-@VC_fXRUN2R8RcFm) zwAJ1?n!o7>cM0n?1-P3eS>{;Q9f}frFX!jB0K0RsZ~}2PM6Rk;(ZrJxf_u6(_~6>^ ztUebI(gi~{aZhxavQrLV*Z+KYKo%L6X^FX)# zy@GpkLi(Q1Cx2Hz))SI(fhvOMTk_MU9}%3xMHPY1lm)W|LWQYr86f^4tssmewXTH= z0b3xKo9q~0<~V>_i}-lK@0gJaxh0uAv9YRE37W4RptL8hv08XFs0ai1#sP5iV1x%+ zQkmRUPy4B{57wX5;7z3B?p^DB5i9(luvymO-(=*6ZL%! zE}5EcK3-`)SG0bt6+l)oq$W>1OySaZ%LMW{mL|Nwl;&SY-%L_mM?w{WnYC@94W<}S ztHAf?n(Ezh-U6z-Y(J%_zy>q`cq~PwVrCT}A`e7Z*a97y?+W?%3d>cg;(k-rV4+a5 zak~y5KNSA)2E8S**PGXSHo`DWM|o15XfgF9M`NxX zEsM+CtDxgwTv_|+J*fgzHPJWH-oJLi&Gef0R%}}M!P(7C@Hq(vTbvuEk~_z5@O=AL ztei8Jtc?%!_;&il^$X}Q6?cMt=Mzvf4|TG9ByvGToE{@zAf1taK`83qTAAD31dA4+ zlluIbY>Mw7mlorH{$2q)OvFAyY_`nd5U)NF4 zLLZrcG{0D2T}yqW92pE)5~Oby4+6)1IpUJ+SiCb}ZvbF(E9LuBAwnkYN>5wg-_>H^a_hd`%8k)OfN&9g0WAHd=kr1i?Kb~-2oM7O+H z(A8r@$sU-)PvQG-mCoxYbnvF?nMcPWzb+UZf2bK8<+S>QWAc?c1z&3TxO|El3Y3)= z1oSE7gL@#c%Vx2S_r>;?l&w=W*vLZLe#i^6sJw@6{Qd@7PjSGEW%A@>Y{F6NEr$!B zla1+#a%d=p5dn3+r_+2w<2i)iFNH1MelWkVeKcyF5u{@z^7&bt8lIHcdfbg=82WI` z2F&=&HFC@1tYb6ivN_xMndYe`JAQ!VL{e-#sYQ%kzu%Zlxc_T2%1;81?dNrnP$f6L8#mqNqk6R z;P9qa?y_v=b4*1|+uPo?BFVCgc=sC1^mrP7bq=;WcgaPBtKN4jSWbU+m{ly;T$jBN zxR-PfJIiN|W%k+hQ4-wz-5_)}Zdz}vovq^Cwaxlo-`$G0_7@cNeK^nuv+|$yNn{xp z2lC8zVaWR`cVp9D<$jy-UHsoUk9fjsNVALtezZMsC!}?$x~NYPzU2Ptrb5N?@)0?i zSg;)+NIK}57N=u39jCq=n|^&-00!0cnTvfj$OfA4Japb|-i)v(x&r@on`H&MRO{MAhYt55 zct%3RNR7V%JAYM_+cdd^Q31cYtAUPEI+5neV>ceX;-Z&6%6k@-YcKoaDkO;l>8Dla zHxWH`3#fhiRRqn3g=602?r~H-rMTtr4A#IUhO-Y19fe>6PpM!byty*Aks*zoEUdnK4#1ztCl4#=^fIX!uEc%%7;B0ptKBlS+&+{Z49 z9w_OPnzZaL(T{SFrpAa7+IIQr>v~?t_Y(S1jv6m#L}I8IGlv2oJp&|SKdH$6@?<%pPtAlEl(N$+cs6ZK0Om-Jw1jvh(|>vv63^JRlzL@I2&ABo?+i>Izq+Kv|nj1S1zZB2|=rcN!frwongfK;38k_PAb7OV_*tQ zy6E9%dXzIvimNb7V2(YKB2A&|_?8%3)dq})@cwR?>wdwZu)0Lp&TLePY2BW!G+5=JA7Te=*821A#w1ip%&R=fhy*}j+BERP6?&=>ld;N}`Q?%6ylJ;?5 zXN7)_mulaqV=z6`S_PMW$L9VHhYLxI(m&Ub1`BbyYf8;TN#qeFzp)IQ+{k(y8-z7EkrOwoO{y^LS*jhC~gd<9lMZSH&-x|@xmvJMZZL) z3_zDo(e+=VA@X;+m3_BjThxmn#&}RoadydvyS;>lAFemsfFI%uCCgk%Sq0UDx}l7b z-_O)#o67fSF->z8%oX&XaBq=RU+F|7K&{|+ullKJ^?i##P~!8SyqjO(@9IxVGHEZrHxyYpkr0P@_3;>H6RDk z76p##)+?`vwsTEUrwWA|$b`trV*L2v-b9%8(POI>PmEvIM%j+D$beyCOu<4AvQASE z6XD~=&mJAw^(IfQ=lIB~kUuW`z=It||BIrTG^AWd7j+>~jF5=?-#`J5A&TXP_POFP zzp77HH;vc)_N&gJTK&+ex0*uRG`5W~)-K?+Wc@HKHoN>`Gn)aGXu%pqU0cy(Su5x) zPj7f8#a@9TRhUzitb?N+NFdlF7X+$GD>4<{XVHpiu8vBSWGb*NC^|NNy{Ja!!NAx+Ns@)CyRiM)BTF+L)xuhBHq@%C zTH86yk)w@2nA2`yjfxqBInD2JFLWj@wAV7G^4z+H3_{C~$JY+E<`SCycxbsrhPQ;9 z(erVu4(-{DpO!G~J;k!3GA&d3=?+=X=uu%{s@t&O#RjX)JC4T7Gn5?q1;NF?12s58 zD6-mW7RztPXvkk+YT8y+)U#X&7?23T)MUeXvF^=Q=Im(u{(08A!Etu=WlhycFUSJP zLuD|oE^7Xc$8lKc~*KJ$UD(&)5>d?`C z-;jattme1HHe>N2Xc_;u+B3bYs&Le@#}^j=)Q;;3#6V3k*Xjw>7}|t;F@}hX1Nn#- zz_dQ2-Z_G7TQHlqKXOXX?_g^1$jSdoRmI2ZueR#icR%p{KkU6_R8?KyH)@h1CEeXf zBeLn1u1%+ONK1nVn^3yDq#GopL;aoN7(O#<$k6h_YjB4}h6t-Q*$518U)vlP7)-D@WY+8V8v zlAeNaeZl$az8z3>t zf>@v91M+{=my5aYB$?rmLhfQjRzTS#e!(E6`d)C&C?#M!nu<|N-i>59%c(Fetd?4g zju=ivFJm^|(51me|CIz_*ojfFDO9p% z2fn;u-ua^#-{YI5oBcLg|N9tva=J1AI0|yB;#@emQw*? zIA2`lWT$q9brwvqgL}x6GPvQZ`T}mTmDA9;#dE(7wDqJ}IJmA-(+);U3kaN_+-#pg z(jz!yHV%v20b9OFKU(8a@x5`;zM|y$eNC{sDwfa{SMJC(!twyvl#Us|6)I`eWwMWI z*NMU5xvIW%_|)yI;6aFDyBLHllqLSHlt6P^@Nn>mChN0R;-Kuo<8t4tVoeIn0}ecw z=jM!p`%iVBhjA|@6&+a8VjQrJw^BY|@?tbRk%=zU(0cDt)a@zvYu1Bh@z{@=3+9i` zR}Dzr=dRKdeuyL-U!MOAG`=gXlD3Xf2;*}ZgJ?3|j6nsMy7fU(LBiE4rf1mt@JS}chem=S0*MzcN(lH zY!1lI9XzTa8Vc#hDfc=H4X6$FN>|+j2x+VbwtF*B4>^o1FJ;d3O~V|yqP{(?NfRGw zuDSlvoPV)e53@4XQvA}GW(TvCS7O8;Z3^1jBCm&dxs@e{$+L*p6ui?4&y(Ck;LY{5 zWZrP)*plt&(i-nosdDRX0o`zkq15L9AhI%JW#TS+2tXGlWs|}n5?c@MnbCKB{~fh! z4m-i|1D6Y=7jCW=vUORQIfcgJPM@;@O@;i^F-<77&PDn+siN)fo#Q ziz5XAFmy0XeOy&QeZuWuQ-=(62lOkd-iIjyO-g3M;LtbO1hI65R9w z&)@1#5yE`ji0cTIRP|~B43}+u0PL3iQtxH?a!UlCo`_Oa`*SEq7&Og#jXP$dYs=KK z#7d^%?UkfH0a!CzB{V#b%gn1H?4yYW?xd@1`kWU%2l1ItM$wfnc@h@D(#T(}X?1n5 z)SQCOJ4?4xZPH--Ib(Uw?E6rT8u8=8pY+T$mHtj}ISU8YoeD?&>-2=ZC?{foMl_Fq znd=L0C0RksYwIf%^unO!;wGXMvZv2=Tn5@hZK>L1Im+{`ymSilm9I#Dufc+n5mvG! z=F?MO)P5JBwuiE~@h;rP4r2kMMJGKt5vNjmdNyX2sXEF!D3u>Sq}bZG{xPcofM+}x ze02DvQiX`=(<)Q8vVG2q1tqU+Urdgn7{|$UI|p<1peVZ~r%Y{^drb5T)kyVIP4CBb z$%v8#@@vZ!(dc%79>E?2`fGiUtVpG@XaygI0y4^oJt$k_C7kj|VdIU{&bSm~I+ipM zl6FLoi6PmB0>3~>}>({SSwAjpfY5lv0tqCFLvb6L&Ln@%yms21p&xb6%Zd#K!sc}m!( zOV2`dRgcQvgv=f38u4}b^$)cR98s@PuhA|HfjW`Sie9#y;@2ktbH($;N!op03c93Y z9759yK;gI-9w6lEz@bp}W_GIp2!*`zFSTQ{&sONEI&dXl`(T}{$8RlacALj%Jl>mV zda%r|Cb;YRYw#>>Xpb~H%rSOI5d?U?cNesG)9gW4phE@D-bsCRVlPv0r7l$A_~>gu z;!2lgRZ=-CR2>~#qnlR5GzUo@i3g6AZv)x@>ag4y_Iv+`^3tBLW|Y9@1Cr}x&h3G6 zRHT%1GEhc&y9}1N#G*}*`ujumRt!R!99`4C0NU;r;1}%>E&qB|l4rZ*eV}%MuBKIC zZUfGy^+Kb%+S5o>>Ri=014}AVP5AI*Cl?_4`Kzy6>B9=AU(5hrX14U;yqramd&K=~ z?kX??S@w4|N?!#|24Rf!x_z{A@5p`?m(auaFmN!Iu3%s<1=`;e!963@w0vcC^3-jR zH~M=53$nOCDXzxRMrf;5gCNbM;)1Hcrl+FLd=IFY_6Xn{HT~8qW)lW1ZtZ(XZ9xW(=kosO8yh22foYKZupB)g= zl(z<%L$Ua(Mc?;Xhm6k zb@w3>=wEok6A4iFn&6$TY9N)ayXI0nE8dm<`dx9b5$fY6dtUS? zGkgp^B#9NWR)7fZ#Rh_^6W^mLT*~6fA#b9B(UEvT)m64M#!Gt}Qd(04yq3(k_FFXq3x5$zLCLebhNm=a!$@3U3noOo_?OUyAsfIlpdqMl zgEmnhGn{$I&&Bim>9uhSpgTy|g5exti!`7EuwBCZFGek(TR%$nknKIhiu_bTE1Yc^ zz51N`K!Gng&?VpI_jN(;MnD3e)r*C!`A0hYu6nVYlRII^K`=vUC+QzjlT}( z|1&iGyncLR&)-3~|M`V!u}lREkL!~%`xCJ} z7{S+o{ZHlL7K*4%Z~ykzBZZ|f3b2y}&&UIgcGHRTib|iJw!44{&EEP_^lxKS9U`1I z*e)5Kp-V?Vys-)}ST3HX#j@E*^9ANDsG%Kx|T!n^v- zpmDMYfU;;6CsBZax;d)N#Gy=f-}qiX~!?mA>Kf#L@nSV zl5U{sj+l|>`rn!I+g&%4vw6Dd5AC^e;c3`7N9J zpXq~t8xBqbd(mz5V!hv6`~SRoLkj@kB%EHxerozE@QGQvJ~16a;jhsYn05GQQ1!lp_Y{Rss4087^k|oCQt`Z3D2l*#SbS{#AHm-3_qC+Tei# zVB1!_AQ_kfUWb+N1v=OL;Hw|#*3!XiD3x14oIwDrVJo&5Bc=UtsRze1xZ)PBp7YQ? zJW;i6bglb3`Va_zPyyt%LU2A@X0QTej~X~fiT4^X-GDsn-E0P)m)|E}tP_#A!PQ&< zXb10F{0^(z`25o?)OafPqc(jnR z(*(){i$ICxAzb19A0Q(<`H)!HHnTlR{ojrFQ!LOc;xauybvu2DHVD^eENj>*noI|9 zdu7E~QmM5Y6t?pjqac!{gvVZssEb4kXOeYHEkFhW2B4ZxP~?QpyA&JJ$V%?Qv(gU$ zGhuv!r{}CZLkl~O6Qtw+k5WYJ$6D`;mYa)~MF4ONIh_N_wXHyqa`Xp}Tic?lFN@_qC&fkp)xh{sW9Jf%pATLIPCzzumOqc`2fSHrK*neUusG%X>%UMi z{i5VE;anlr@ppw80f?d&Kj-#*@X7#+YBCxQrONv7YVvggGMqLTRG62{xQpI_{u7XU zrP}juCVa0n&H!0Jb0SPPSR0T^+CZ0A+tr7yMUDY*ju9b2Y_#t}Pk|aD!uw+RZPI># z;{p0F9|0x_5uywinRrI=-f~@F%KFaDS%=UP=UXZL7jRnM@vZKN)r){P0PnSc2G}dc zJ|AWTUvs@yElw8?)(3oP!%sjXbRI<>uHE?r2)!LKwrYGljH>`j1rsE)25xIUp!*&G zUI!~l(JVY7HUffw=wQB@ue$$QL=A<&;l*CPGu*;=DY*Xe&VnQ6#gQqV8|#|7eYgY- z*N}VA945F1hutD@Hi`%Wi9F1rKV1 z{@=TGn)8VYGtm(s;GyYuP0m3AKw@*PU5Ak>kSky)8p{UC8ebZSyfMmZ0b=Ou$chAI z{dEQ?Xq`3lio!KL36T8Skm6*GhWjANA0D6Pmj<-P`!??DQl~zKjV{(tmR&kkQvHg| zeV}z`^QO3Kia%98t1#o;gtW!d<6}4kBRjtzxE*V|g2}H*R-R zq#Y^^w9i|QJZP0p9Y~CG=eFXQI6Y=Q6r>3Z&@#G|CIh`Zhn8OMxLh;_xu^>a1W6%x zRts~c0d;Nm$VxMaq1f<-B=n?g*e9LlYclnuX*tjC=SC#NcHH!Q==nj(K9R7>R{O`6 z0a?;JJx6fagdjz)C2R`huH)vYL2-kZKnQ3l%_@F5TvsaKoe=3lm`r#N3 zEBap2_{-5t6(^j$=Ndbxma_bxXqg4~xZUL@LCE~WL;FUA0%oo`fRl5;X#fCpoQ*_h z_-5{q9j&O@a+J0w9+gS2IXwk{9mbvbSh6U2?#h=65;{NjI=zAe*h|3aHKWXAMDM^ZE zu=HW({Xm>l#WnSDD3!6;m@oaID^A(vyYS2U6}@GTXvw4b*bLrXKWUK*!Vt*->jVBc zh{p%oDS#j(YWqZ}_m=())BytE;y$2-D=&)OLI>yTzgY-?2GFL%s*!HJey+V(v5P?F zvzR2i7!feVL$nOXWqqRw84{X?1;>4@!O23#j9geh_F0sG2`9|o0P3A6=^efwg^J#c zD1`^In^frK%MqyRE%$R}FMrVi1FB@zvK{y~u`Z=7{P;Z?m z7k8gxa#1-}(XGdY?VR?KO3|c;`fbZBy+)TobHnm8PLUTIxYKrkCms1Oz}`>G3SUJfmeGe_%&0$70?W|4fye1W**( z4mf3wN%Mkly>XH)gh=IOsOjtc_tanCnl}No73a2f@-a{uy9mfl(0>Y8jF);emClFu zg-mJ}t(>iiRBSvP-56Eep!(K8i6nuP;xCE)C2DOm!1!Tu!hx>9B)WdZF%W%`~#7SXVe2uj1F~VE4mI44idHTsE~nx>xSP82t7p@U?%N* zeAb~^y*&kjxoo_9_xH(-?nFbR6Lr}O+b|NMBZY(IlfSKF7im`f@T0E+Q0FhmcwOz7 z4~E-0uf7Pynf3(m94td7I19yYWtUne?NvQd!gav#ENT+=Icp=6RAiuirEcWl){@iS zilo1y`PuNxmXpk_DJiM_53fGpR!q&$pd@pr~5=!S`vz22A>u6p}4<09~cTtaQ3wp%;@VwyDu6h)nnb zwJ2E!uODf*yFa0Lc--Nb#W3biE^6^;fJU~1YC89zK>Lt{R_1$7pPTODreA?b3LKG- zH()$=((H}90Fdh&Fi|9Jk?NKN;aTMqfTp8|nuCU}wkFum z-{FH(`MQR6^BBOxv0^OpQ4RgRCHio>(S5*mNc`!Z5YzFH)YPqYHGY1?`GH;0F@H=r z_jD~K7X?bL!=h`9u0&XR_LLL31fwiZGrjw|~lcRgE zJSYMOUUH%w)^5OljL!9hL+cX^JI9^@PCm(31yxXJS`VGtXeD46d6OL(u{8aFxzG^tM$~&xc~H$Z<#-S zy&4Wr?QH#d#Jo13^(;~$MkZ>6u0kD5W80DiKDz7Lf$m5deuOIRt zim4s=22hhW3VGg*zQH{Qoz=E2#kt@1-gpT&viSQght#ctws0uLwlR!zrDpz z6<>F}a9m8h8Kn4GV^xYI-xPzCy?N~xxJqg0Qx3_;>?ro3b5iU)wA1Ksxa?m&HOgRD zc+K77!eGPZo33+WVSFNY??r$jaL;kdDjZtz4^iuus9Ji%^fjQ~ea@eTi~GWw&-Y}#{6-T6 z%}8hf)+$a9Yjr5Yd0rKCCY*?oE@|4c4Vo(5t9L}GC>Y~Pr=vX?U~%j+irEIcxv%iB zjg-!F_{Y@KXMqSe1_H))cf99wb@#W*>6h%mqWGj7E2%Q$fN!qR13+`JtPHh${7qJl zd!^Y*k6$`wAyd%cVIEnjAkkCGbW?qC|LS+}sp#IgTlNpXAd+-jP1d}XfoFCZUs5HC zMX~u}_9>ZlSU;%22I6|w7Y?RHg+JqSer45S^-&v3IrXzH5-pKNQP3m}q{D6Hh!0Ap zaHgDx!YU~X$V&{}#`jaALNAU4;qoHv8rtdw+Kf8$U?DlkUteg^^r|4(DT=?J#W-<` zh#4BE=5TsYTf&vVY(6Jv(Rw#d{kWp4!^|Kc4-(>Tg{p&btrr^`CDX4!pmQq^2;mw2noCNFSMs@r!L)IIsD> zPWi+rEMWIxKn;*mUzE~HR}3`|`hlh2YZdNxD;Hv(DmyRi=8M^y#H<)^fb%-(JqY&~ zOOfq-i0UW0=rVNIqo(kHB>pa3EYF6P)V}bF_*;?~KJ|*quL>@p^rK**Z9OB_xNkz~ ztdNELuoMI%?3lf6IfsOV$VjnWu%M#INHs-42RkmUZaTHT>6oDC#gmwz4U8?Zt9tf> zR{vMNZcNj$x^-s^yvE>1y?Dwfnta_*4B&ac7b&_TmYrDe!0BDKQJ~jVa|?c2*R7> zxUPiya-fBFCW1k6Loh&l2lU0gnLav^~pThQ%e<=lZ(hNF?j(iRx?oQ1$Ly>QGFLBBW>5?NY zi<;o10rL>MXtYdMsVl*2{gMQTFD2yAc@=g`YlkL4?GE3518Vme*rL&_%igLy?d-H& zn)O$Z%o5E6x@J~`3}3j9gl_5769<7so36K%c()Ei)?yze@2gopde5&Zs;XXA-rf*3 z7<)(-g^{+pn4*QHM$d^cU<~j_Sg~+T$T)d0DwrOUoqAeoMMVW^qS!LyT~XsiOmAbF z>uYsuc}1}&C|^<>lXI;H+KUf+ij(;eMX&3Ut_6Pxk-{GDeEXg*i{Z3L%$`a^GyOwa z!_jGraw6BNb|pmj(LuVB@NY87aK<%(#l$T%(=6mY3TR?c)hu>)RNcJ^_WD*;e~e77 zF4>b4o<-;M0Ah4p$Jmwe7>u~}?!~8~HqvFiMGkJ7QEsWo=7+OgFxvQVx-2%Rei{>L zub|2qLC{kP3uDRwMw7k=k1@ovOzNdn!Iq;7F7qTIh2qVM9c2`sDPpS!6IZS$$BTfn#jH*{ zyd@0|tQzd=uFC4y1^^Deit(xVfuh*f7flIlOk{M1*8A-9PKU)t!5VMLp5{9twv~zXi}SrbVHB@(fnVJL@O|#VbtQR{jK}x@Pe0Iu9#vlM zxLCAqgMq>vPQLo?{61%qFAflksb+yNtAosiGqGY8JnQ$iZRk5HP%9OV_k0kmF_^uy zQrae1Y{~)zSH6!E%qviT`{J0j35V;;1@}4}4(1@!E;yxt&dDU)n?wvqso;=oeNpg* z<>}hc2O+KXbz;{%uIi+8NIg1IzYq|3yMvK@sKgY_e^hSc?s)zZs2RO{dA1nTvBuRi zr1oM@*n6Pkqt>KJ@u?@^IHE+n2cGggWe+=Rf`tHS#Ity8G_I$45?(CBABJjVetBFU zvZ#(Qcl}0WhItDRwqo_-!P0f__XSCHJL3!k z*JoCmA75od?FS`A5wbZ0&E@T&@o(|C&n(m}D20aLHM0$6n4!O`@znk`MQqsYbIo)* zRK;d!YrhI9V;LPXuO!xsbn6FpQa--P1T7-&!6rpP3!kbBL|C19k5Y34EN>$SBquAM z{%^S%U+aG+k2;t+GH&Dnw6xiB((Ix_a9!JO=xV+H55Op^01QWGCa~wFno!CYNWXyd z{B%(uBt7>DXmtaUjKU5hBW>^$(Z0i-8vribX_oHZo7_&B(Xh@qPgQm;lNw%Ht1#M= zQ5WFb+|f`ZTB?%HMC-O}m+F#yPQ^iBT);R07CG@W#u*79%YvKYO0kRJ*D<@pb`ufc2wD!~sv{Dc2;}gH-wDhi%QpcJyxX{*h!TCY-4*={_mwoo3 zVnJ*%@&Ng@RYqugC!8+$laMZ8rTDWWX1>+=0W_chviX?w9^7WikV9$%r>ilhPR&Z# z{m=lH8)NI6=nFd0FeFbIAnrp2HvPemi}4R2C;7ZHK%Q`R8vvHk+%61}{Ne0aFZ}~r zs#Cn)k@uRo5*J6zjxCTWfdN$H+^*cVM7r$=puW{|Acz&-)hO*l(PTH=n7lit5J9^u z!>^eY8Mn{oh+b*D0vZRz8bVYN*xu5CkzsX)98gKQ;-;urqRPDPHukuIIv{@&Dxfd1 zNuUW7+m=AIl11S>BnerL=GM7liz}S2m99_Rzy8A@kd&eN7A<`HZo0Yxs zUP>cxcep-ZjxJZ1KTZ7Q>_noBycVh)T2gGg4+C{%;zB=+WF#k%S(z>?)tGMgH-{$E zg%4)n4nN6>;_I@rAl|xxOFLhGX(kqZ`H#LBHz#e5oAe5H4g?Wu8$jERc`p<^D4Y76t?Yc>F>8&E zMK*gAlUsD${ZU7;VyJbGuPpsS3PcnP+34GE!JTpKpunT`4x*`U+S4)3z!fF*0G&DD z)J#&E%6J28^%EtDze=n~V!*K=f|EOhnUJP+po{(Reg9KynxtxDtX5f4##of)I42;O z*WTrjY##@s?OtLKsh`EpiW&@%ZZ!c?^^Zd-{c~AnKxOR&70xTZq5hjrLrZ zeEkg+@i@N0#IEp-WZhtKVV>+SOh3Qbo&d-<`rurQkGdAEoB_yuP;MULj+wVIEKqLu z^(X19GT#9oYcI@XlaGP!OTR>nSJQ}#Wy+x&MFZy3F|r=97I+T1L7<^-uxmLk9V+~d zsv5JBjW?YRy3@-4Sb*u={l~>2vm*&0IMJ=lXN8-D2rR@5j@pE{m%;cX^+}?w>8Ku{ zV3vMIlPZ`2oHQZ0kFH2rsdl5njE-W?P+Erz5)=%>FUFw^=Eh3wcHQcEpSblcX=QGye$Un?LrqFi zryZ*u>KH#%o@I8azu(&eiiGT&yrkR$0!M7i3x@1WoW`k|M&Yh0E33gA!7o>v+!$EVqsX6g?5`X#1l6BVi(lQ$UkUx#z5DD9<-xKW z3!e!jgV{-pCZN`xID_Fs=ws+*Qq zpX6c0QM<+X(C=T5ck9j=^Z)$7m=~3U+n@LUD$|m?1ubK?hlYlRufXx;pVvZK(HB04IX-|JQ(HNP*bH)+_Y=Q&TzxiDrpG@5 zEK-worBeC-cnWb%!}G&cGvIfgr{C<6Cl>UO`{y$%BCfs+p#^5$4};YFIg|mDz7yHTfg!wCbrdF~)?vOs`6~eWCr7=va$|PimHUBw~H<~N~`8Ny_)p$K$(j%2j zVEStjoFN%Tm;@}oC4nh*HgYnG`-naBpTC}oe;+CNP33sK*`J@0qXR#CcVW|nKj*A4 zJMgl`d0y(&_m|H|!Gbz`)y?`BFKc7qWzG0idp-FtpFw~H_Ui38y}x)_e*s?BYN0j# z-2Z-y!28!7Zh?K5u~Ga_pW-*b%eoNXI?eho7TAYy3v5KSmmz-{!FOOuv5!BU_=^Sh ziU_d4MlbmO7cXmiNw5(Zm$;Pu%>vsW{3NN;6wSY9781EqC>Z50n%laZe|{E;2!0Zh zSta}D%lW@A?*HwJ%d~i%r??iE6p~TSLFT1lw#LH9TijhWQli(B$RjT+S9fBaGN&lB=jfwh5V$iuKR)y1Ot*8m=bW>K-r{%r1c7p$%_>Wy1Lo@9@B^g&5uTO5$ zwQYP~!f?rF(jDt0B7{*{CtIl4pOZD424gd`5Q8PDweBuUN-L$8)W847&loeS;nGj1LPgbMpq;wC3VjpOtB?G(fr0E)K4UZ2vOwdG+ zldY)*m`;6l^(eDeReQC?P;1{3_HVZxoFi=FpSNYvx3U|lTVpkRx0ng+Xs$7FK7av` zVK!ED6E1_}^R3i*c@PEes7%0PlbGfSPzFi6bB&1Baw$B0q4?~rK0*L}-V0}bGRwLy z0p=qwh=0TNu7C}6sOJ+!S2AwX=GC5rlzON2Y2T;oFL$P=e9;KGq%W|#dwTjV&yRKl z!3E7hXuh5JV`nA>v`?9G(|i@xR`s7DCW}*q(O{$l0-@00~eOt*0(qCdoXu zgxa1b*Kl`mI9hA+=;KyVaW81cYb^#WcnY)>D zkZ))30V7f-n}G+vEi`h*JK%r_C$4GrYN=?_-YT>`!s7H$`uSO@Dh2`|C4JyX_`bB* z7DVUuNxpgqV2o@9&sVS;rALTtDnzo!hLcZ+q{x!M+n6td%VMRFyqu(%+P~H{sWRqW&bsY;GNu9a^LCN%#^{WbjJu{&)kdQF&sM zfUg)D6sz>c_vX}h4de#geJQ+()owM(`4y)C{)XJY33$p}aC_O;d&zEKIQrmKCSFAY zQtxAB{=E>obT&AU=k=;`^MF69$!`(P8*TC`k&!a0gcNlc!QG}-G&TMosY343KTFjZ zzke$yP!u?cCE$Nqas@AZ&5%<}HN5z!9r1vGquoajKneSRgKM(7TMVOx2NK&Q)Bf`=1xF4DbHJ>Ue{r3v`~_zW|#qxq#NL3 zSBxbM7DJRXMZGW&9O%9oT>rfG8VHgs^>OE6h{WtR!~Iw-GuJXAwYdv@9#yHE*8@1( z>xyHbEhd9MT!=FQe#uIaQ`vqSu~oq1m<@CmOMyC2oNaS%*l71AfJ{`+Fqw?}EEZFK z%ZV4c1De`qfE=zzqVU#9_?N}%N63mXm038j|E5&XQC*{tFdo=@NY80obz4p$%5PD+ zd?hfS9D(u{gUgo<%K}B=Y*hQhfsIocHJgE=pB;YRNw@eSgpDu*~+ zh4&-1GYa5#?7GfF5=p80(3t|+%@00Ea(Z zFBxjPlr{`EYvwDY8Tz;=YE?+1ZVq~H)@^Ewl@uH zOXkn|%6p=N2bWBWab6tTWwrq-k7NT^DAmXDrr4t7sy2EE>IQ=-cF9+ z%iILR!~=L$sR|>3M!?-XiNv?NfB%|Ur?&Scm6|Ij&V6$#sqm88&nqLy$xS2WY=!J~ ztRKGJ;Ws$(?4$@%(=>NU6B(>-7=N%1``@!jxDS`yqDb_rKGJezyr#G0eAR4YgefLa zz8)nRU}w4C?Fujg<+E2;3EyLJcs{(~14x%7G#aeZVm8sckG54yxm3d9z;aM2rl|R$ zq->a3H$cPau^f+32DQd(t}h0!`YW1r+Ka&)TwyEB?5l#AxPE@+9*q0Egc?#A{)p(c z`R~S|RyaA?2XAL9cOL9Tlu4C#;xcO*E)eGJD*jB6InYtFyns90Yξ6W%<_a2$10 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 literal 0 HcmV?d00001 diff --git a/plugins/storage/object/cloudian/pom.xml b/plugins/storage/object/cloudian/pom.xml new file mode 100644 index 000000000000..9bc462880934 --- /dev/null +++ b/plugins/storage/object/cloudian/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + cloud-plugin-storage-object-cloudian + Apache CloudStack Plugin - Cloudian HyperStore object storage provider + + org.apache.cloudstack + cloudstack-plugins + 4.20.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 + + + 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..2a85c334e939 --- /dev/null +++ b/plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudianHyperStoreObjectStoreDriverImpl.java @@ -0,0 +1,867 @@ +/* + * 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 org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +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 { + protected Logger logger = LogManager.getLogger(CloudianHyperStoreObjectStoreDriverImpl.class); + + @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); + } + + // 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. + */ + private 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. + DeleteAccessKeyRequest deleteAccessKeyRequest = new DeleteAccessKeyRequest(); + deleteAccessKeyRequest.setUserName(iamUser); + deleteAccessKeyRequest.setAccessKeyId(accessKeyMetadata.getAccessKeyId()); + logger.info("Deleting un-managed IAM AccessKeyId {} for IAM User {}", accessKeyMetadata.getAccessKeyId(), iamUser); + iamClient.deleteAccessKey(deleteAccessKeyRequest); + } + } 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()) { + DeleteAccessKeyRequest deleteAccessKeyRequest = new DeleteAccessKeyRequest(); + deleteAccessKeyRequest.setUserName(iamUser); + deleteAccessKeyRequest.setAccessKeyId(accessKeyMetadata.getAccessKeyId()); + logger.info("Deleting un-managed IAM AccessKeyId {} for IAM User {}", accessKeyMetadata.getAccessKeyId(), iamUser); + iamClient.deleteAccessKey(deleteAccessKeyRequest); + } + } + + // 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; + } + + /** + * 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.KEY_S3_ENDPOINT_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 size bytes. + * @param bucket the bucket + * @param storeId the store + * @param size the GB (1000^3) size to set the quota to. + * @throws CloudRuntimeException is always thrown as Cloudian does not currently + * implement bucket quotas. + */ + @Override + public void setBucketQuota(BucketTO bucket, long storeId, long size) { + logger.warn("Unable to set quota for bucket \"{}\" to {}GB. Cloudian does not implement Bucket Quota.", bucket.getName(), size); + throw new CloudRuntimeException("This bucket does not support quotas."); + } + + /** + * 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.KEY_ADMIN_USER); + String adminPassword = storeDetails.get(CloudianHyperStoreUtil.KEY_ADMIN_PASS); + String strValidateSSL = storeDetails.get(CloudianHyperStoreUtil.KEY_ADMIN_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.KEY_S3_ENDPOINT_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.KEY_IAM_ENDPOINT_URL); + logger.debug("Creating a new IAM connection to {} for {}", iamUrl, credential.getAccessKey()); + + return CloudianHyperStoreUtil.getIAMClient(iamUrl, credential.getAccessKey(), credential.getSecretKey()); + } + +} \ No newline at end of file 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..95f071ed26dc --- /dev/null +++ b/plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/CloudianHyperStoreObjectStoreLifeCycleImpl.java @@ -0,0 +1,156 @@ +// 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); + + // Build the details map + @SuppressWarnings("unchecked") + Map details_from_gui = (Map) dsInfos.get(CloudianHyperStoreUtil.STORE_KEY_DETAILS); + if (details_from_gui == null) { + String msg = String.format("Unexpected null receiving Object Store initialization \"%s\"", CloudianHyperStoreUtil.STORE_KEY_DETAILS); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + + // The Admin Username/Password are available respectively as accesskey/secretkey + String adminUsername = details_from_gui.get(CloudianHyperStoreUtil.GUI_DETAILS_KEY_ACCESSKEY); + String adminPassword = details_from_gui.get(CloudianHyperStoreUtil.GUI_DETAILS_KEY_SECRETKEY); + String validateSSL = details_from_gui.get(CloudianHyperStoreUtil.GUI_DETAILS_KEY_VALIDATE_SSL); + boolean adminValidateSSL = Boolean.parseBoolean(validateSSL); + String s3Url = details_from_gui.get(CloudianHyperStoreUtil.GUI_DETAILS_KEY_S3_URL); + String iamUrl = details_from_gui.get(CloudianHyperStoreUtil.GUI_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."); + } + Map details = new HashMap(); + details.put(CloudianHyperStoreUtil.KEY_ADMIN_USER, adminUsername); + details.put(CloudianHyperStoreUtil.KEY_ADMIN_PASS, adminPassword); + details.put(CloudianHyperStoreUtil.KEY_ADMIN_VALIDATE_SSL, Boolean.toString(adminValidateSSL)); + details.put(CloudianHyperStoreUtil.KEY_S3_ENDPOINT_URL, s3Url); + details.put(CloudianHyperStoreUtil.KEY_IAM_ENDPOINT_URL, iamUrl); + + // 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(); + logger.info("Successfully connected to HyperStore: {}", version); + + // Validate S3 and IAM Service URLs. + CloudianHyperStoreUtil.validateS3Url(s3Url); + CloudianHyperStoreUtil.validateIAMUrl(iamUrl); + + 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..11e574b4a5f1 --- /dev/null +++ b/plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/util/CloudianHyperStoreUtil.java @@ -0,0 +1,214 @@ +// 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"; + + // GUI Object Store Details map key names + public static final String GUI_DETAILS_KEY_ACCESSKEY = "accesskey"; + public static final String GUI_DETAILS_KEY_SECRETKEY = "secretkey"; + public static final String GUI_DETAILS_KEY_VALIDATE_SSL = "validateSSL"; + public static final String GUI_DETAILS_KEY_S3_URL = "s3Url"; + public static final String GUI_DETAILS_KEY_IAM_URL = "iamUrl"; + + // detail map key names + public static final String KEY_ADMIN_USER = "hs_AdminUser"; + public static final String KEY_ADMIN_PASS = "hs_AdminPass"; + public static final String KEY_ADMIN_VALIDATE_SSL = "hs_AdminValidateSSL"; + public static final String KEY_S3_ENDPOINT_URL = "hs_S3EndpointURL"; + 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_ENDPOINT_URL = "hs_IAMEndpointURL"; + 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 = 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; + static { + StringBuilder sb = new StringBuilder(); + sb.append("{\n"); + sb.append(" \"Version\": \"2012-10-17\",\n"); + sb.append(" \"Statement\": [\n"); + sb.append(" {\n"); + sb.append(" \"Sid\": \"AllowFullS3Access\",\n"); + sb.append(" \"Effect\": \"Allow\",\n"); + sb.append(" \"Action\": [\n"); + sb.append(" \"s3:*\"\n"); + sb.append(" ],\n"); + sb.append(" \"Resource\": \"*\"\n"); + sb.append(" },\n"); + sb.append(" {\n"); + sb.append(" \"Sid\": \"ExceptBucketCreationOrDeletion\",\n"); + sb.append(" \"Effect\": \"Deny\",\n"); + sb.append(" \"Action\": [\n"); + sb.append(" \"s3:createBucket\",\n"); + sb.append(" \"s3:deleteBucket\"\n"); + sb.append(" ],\n"); + sb.append(" \"Resource\": \"*\"\n"); + sb.append(" }\n"); + sb.append(" ]\n"); + sb.append("}\n"); + IAM_USER_POLICY = sb.toString(); + } + + /** + * 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, DEFAULT_ADMIN_PORT); + } catch (MalformedURLException e) { + throw new CloudRuntimeException(e); + } catch (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 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.compareIgnoreCase(e.getErrorCode(), "InvalidAccessKeyId") != 0) { + 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..3ad5c60c0249 --- /dev/null +++ b/plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/driver/CloudianHyperStoreObjectStoreDriverImplTest.java @@ -0,0 +1,406 @@ +// 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.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +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.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.PutUserPolicyRequest; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.CreateBucketRequest; +import com.amazonaws.services.s3.model.SetBucketCrossOriginConfigurationRequest; +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.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.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_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; + + // The StoreDetailMap has Endpoint info and Admin Credentials + StoreDetailsMap = new HashMap(); + StoreDetailsMap.put(CloudianHyperStoreUtil.KEY_S3_ENDPOINT_URL, TEST_S3_URL); + StoreDetailsMap.put(CloudianHyperStoreUtil.KEY_IAM_ENDPOINT_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 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 testCreateUser() 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. + // No buckets to update the IAM keys for + when(bucketDao.listByObjectStoreIdAndAccountId(TEST_STORE_ID, TEST_ACCOUNT_ID)).thenReturn(new ArrayList()); + + // 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)); + } + + @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 testSetBucketQuota() { + BucketTO bucket = mock(BucketTO.class); + when(bucket.getName()).thenReturn(TEST_BUCKET_NAME); + // Quota is not implemented by HyperStore, we throw an CloudRuntimeException. + cloudianHyperStoreObjectStoreDriverImpl.setBucketQuota(bucket, TEST_STORE_ID, 5000L); + } +} 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..b26cf199e17f --- /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.KEY_ADMIN_USER)); + assertEquals(TEST_ADMIN_PASSWORD, updatedDetails.get(CloudianHyperStoreUtil.KEY_ADMIN_PASS)); + assertEquals(TEST_VALIDATE_SSL, updatedDetails.get(CloudianHyperStoreUtil.KEY_ADMIN_VALIDATE_SSL)); + assertEquals(TEST_S3_URL, updatedDetails.get(CloudianHyperStoreUtil.KEY_S3_ENDPOINT_URL)); + assertEquals(TEST_IAM_URL, updatedDetails.get(CloudianHyperStoreUtil.KEY_IAM_ENDPOINT_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/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/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/views/infra/AddObjectStorage.vue b/ui/src/views/infra/AddObjectStorage.vue index 40150d5cd274..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 { From ce267686725fc3838affcf0c6d35e1c90d7bb4e5 Mon Sep 17 00:00:00 2001 From: Thomas O'Dowd Date: Tue, 1 Oct 2024 08:44:32 +0000 Subject: [PATCH 06/14] Fix lint issue in README.md and add more unit tests. --- plugins/storage/object/cloudian/README.md | 4 +- ...oudianHyperStoreObjectStoreDriverImpl.java | 14 +- ...anHyperStoreObjectStoreDriverImplTest.java | 279 +++++++++++++++++- 3 files changed, 291 insertions(+), 6 deletions(-) diff --git a/plugins/storage/object/cloudian/README.md b/plugins/storage/object/cloudian/README.md index 28c24af7af3c..d9ee4c161c15 100644 --- a/plugins/storage/object/cloudian/README.md +++ b/plugins/storage/object/cloudian/README.md @@ -70,6 +70,7 @@ Details MAP ``` 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. @@ -97,7 +98,7 @@ The following additional resources are also created for each HyperStore User. | 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. +| 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 @@ -119,6 +120,7 @@ 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", 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 index 2a85c334e939..a831ecd2dfbc 100644 --- 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 @@ -164,6 +164,16 @@ public boolean createUser(long accountId, long storeId) { 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. @@ -193,7 +203,7 @@ public boolean createUser(long accountId, long storeId) { * @return an AccessKey object for newly created IAM credentials or null if existing credentials were ok * and nothing was created. */ - private AccessKey createIAMCredentials(long storeId, Map details, CloudianCredential credential) { + protected AccessKey createIAMCredentials(long storeId, Map details, CloudianCredential credential) { AmazonIdentityManagement iamClient = getIAMClientByStoreId(storeId, credential); final String iamUser = CloudianHyperStoreUtil.IAM_USER_USERNAME; @@ -360,7 +370,7 @@ private void createHSGroup(CloudianClient client, String hsGroupId, Domain domai // Group exists. Confirm that it is usable. if (! group.getActive()) { - String msg = String.format("The Group %s is Disabled. Consult your HyperStore Administrator.", hsGroupId); + String msg = String.format("The group %s is Disabled. Consult your HyperStore Administrator.", hsGroupId); logger.error(msg); throw new CloudRuntimeException(msg); } 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 index 3ad5c60c0249..e511105c7e6e 100644 --- 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 @@ -19,9 +19,12 @@ 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; @@ -50,14 +53,19 @@ 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; @@ -68,6 +76,7 @@ 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; @@ -75,6 +84,7 @@ 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; @@ -119,6 +129,10 @@ public class CloudianHyperStoreObjectStoreDriverImplTest { 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"; @@ -140,8 +154,15 @@ public void setUp() { 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.KEY_ADMIN_USER, TEST_ADMIN_USER_NAME); + StoreDetailsMap.put(CloudianHyperStoreUtil.KEY_ADMIN_PASS, TEST_ADMIN_PASSWORD); + StoreDetailsMap.put(CloudianHyperStoreUtil.KEY_ADMIN_VALIDATE_SSL, TEST_ADMIN_VALIDATE_SSL); StoreDetailsMap.put(CloudianHyperStoreUtil.KEY_S3_ENDPOINT_URL, TEST_S3_URL); StoreDetailsMap.put(CloudianHyperStoreUtil.KEY_IAM_ENDPOINT_URL, TEST_IAM_URL); when(objectStoreDetailsDao.getDetails(TEST_STORE_ID)).thenReturn(StoreDetailsMap); @@ -163,6 +184,11 @@ 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()); @@ -296,7 +322,7 @@ public void testGetAllBucketsUsageTwoDomains() { } @Test - public void testCreateUser() throws Exception { + 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()); @@ -332,8 +358,13 @@ public void testCreateUser() throws Exception { when(iamClient.createAccessKey(any(CreateAccessKeyRequest.class))).thenReturn(accessKeyResult); // Next Check what will be persisted in DB after everything created. - // No buckets to update the IAM keys for - when(bucketDao.listByObjectStoreIdAndAccountId(TEST_STORE_ID, TEST_ACCOUNT_ID)).thenReturn(new ArrayList()); + // 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 @@ -363,6 +394,134 @@ public void testCreateUser() throws Exception { 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 @@ -403,4 +562,118 @@ public void testSetBucketQuota() { // Quota is not implemented by HyperStore, we throw an CloudRuntimeException. cloudianHyperStoreObjectStoreDriverImpl.setBucketQuota(bucket, TEST_STORE_ID, 5000L); } + + @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")); + } } From 187f79aaf4c902ed0551ba1345babe4b02115b8d Mon Sep 17 00:00:00 2001 From: Thomas O'Dowd Date: Wed, 2 Oct 2024 07:46:18 +0000 Subject: [PATCH 07/14] Fix "end-of-file-fixer" errors at the end of 3 files --- .../org/apache/cloudstack/cloudian/client/CloudianClient.java | 2 +- .../cloudstack/cloudian/client/CloudianUserBucketUsage.java | 2 +- .../driver/CloudianHyperStoreObjectStoreDriverImpl.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 0025bca63000..ff5b4acffbd1 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 @@ -559,4 +559,4 @@ public boolean removeGroup(final String groupId) { } return false; } -} \ No newline at end of file +} 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 index 9efa395c2547..1301bec4b11c 100644 --- 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 @@ -103,4 +103,4 @@ public List getBuckets() { public void setBuckets(List buckets) { this.buckets = buckets; } -} \ No newline at end of file +} 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 index a831ecd2dfbc..c76ea0237c03 100644 --- 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 @@ -874,4 +874,4 @@ protected AmazonIdentityManagement getIAMClientByStoreId(long storeId, CloudianC return CloudianHyperStoreUtil.getIAMClient(iamUrl, credential.getAccessKey(), credential.getSecretKey()); } -} \ No newline at end of file +} From 5665f271e2a7ce401280b130f1e2380084a42236 Mon Sep 17 00:00:00 2001 From: Thomas O'Dowd Date: Fri, 4 Oct 2024 11:49:56 +0000 Subject: [PATCH 08/14] Fix timeout typo for CloudianClient connections - The timeout parameter was using the port so instead of timing out in 10 seconds, it was using 19443 seconds. - Added tests to use real connections instead of mocking and added line tests to try catch other issues. - Noticed that HyperStore and AWS IAM services use return different errorcodes. This will be fixed in HyperStore so handle both errorcodes. --- plugins/storage/object/cloudian/pom.xml | 6 + .../util/CloudianHyperStoreUtil.java | 19 +- .../util/CloudianHyperStoreUtilTest.java | 227 ++++++++++++++++++ 3 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 plugins/storage/object/cloudian/src/test/java/org/apache/cloudstack/storage/datastore/util/CloudianHyperStoreUtilTest.java diff --git a/plugins/storage/object/cloudian/pom.xml b/plugins/storage/object/cloudian/pom.xml index 9bc462880934..0efd4a0294eb 100644 --- a/plugins/storage/object/cloudian/pom.xml +++ b/plugins/storage/object/cloudian/pom.xml @@ -60,5 +60,11 @@ 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/util/CloudianHyperStoreUtil.java b/plugins/storage/object/cloudian/src/main/java/org/apache/cloudstack/storage/datastore/util/CloudianHyperStoreUtil.java index 11e574b4a5f1..87e0c090bfb4 100644 --- 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 @@ -65,7 +65,7 @@ public class CloudianHyperStoreUtil { 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 = 10; + 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"; @@ -97,6 +97,15 @@ public class CloudianHyperStoreUtil { IAM_USER_POLICY = sb.toString(); } + /** + * 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 @@ -115,7 +124,7 @@ public static CloudianClient getCloudianClient(String url, String user, String p if (port == -1) { port = DEFAULT_ADMIN_PORT; } - return new CloudianClient(host, port, scheme, user, pass, validateSSL, DEFAULT_ADMIN_PORT); + return new CloudianClient(host, port, scheme, user, pass, validateSSL, getAdminTimeoutSeconds()); } catch (MalformedURLException e) { throw new CloudRuntimeException(e); } catch (KeyStoreException | NoSuchAlgorithmException | KeyManagementException e) { @@ -194,8 +203,8 @@ public static void validateS3Url(String s3Url) { * 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 was used. The method quietly returns if - * we connect and get the expected error back. + * 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 * @@ -206,7 +215,7 @@ public static void validateIAMUrl(String iamUrl) { AmazonIdentityManagement iamClient = CloudianHyperStoreUtil.getIAMClient(iamUrl, "unknown", "unknown"); iamClient.listAccessKeys(); } catch (AmazonServiceException e) { - if (StringUtils.compareIgnoreCase(e.getErrorCode(), "InvalidAccessKeyId") != 0) { + if (! StringUtils.equalsAnyIgnoreCase(e.getErrorCode(), "InvalidAccessKeyId", "InvalidClientTokenId")) { throw new CloudRuntimeException("Unexpected response from IAM Endpoint.", e); } } 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); + } +} From 957278c3d11ccfef0454fa241d5e390e9d8fdd93 Mon Sep 17 00:00:00 2001 From: Thomas O'Dowd Date: Fri, 11 Oct 2024 03:11:24 +0000 Subject: [PATCH 09/14] Use original GUI store details key names - The Store details are maintained outside of the plugin so it is best to save them using their original key names. --- ...oudianHyperStoreObjectStoreDriverImpl.java | 12 ++++----- ...ianHyperStoreObjectStoreLifeCycleImpl.java | 27 ++++++++----------- .../util/CloudianHyperStoreUtil.java | 21 ++++++--------- ...anHyperStoreObjectStoreDriverImplTest.java | 10 +++---- ...yperStoreObjectStoreLifeCycleImplTest.java | 10 +++---- 5 files changed, 35 insertions(+), 45 deletions(-) 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 index c76ea0237c03..3cbe40e03f6c 100644 --- 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 @@ -425,7 +425,7 @@ public Bucket createBucket(Bucket bucket, boolean objectLock) { // get an s3client using Account Root User Credentials Map storeDetails = _storeDetailsDao.getDetails(storeId); - String s3url = storeDetails.get(CloudianHyperStoreUtil.KEY_S3_ENDPOINT_URL); + 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); @@ -807,9 +807,9 @@ protected CloudianClient getCloudianClientByStoreId(long storeId) { ObjectStoreVO store = _storeDao.findById(storeId); String url = store.getUrl(); Map storeDetails = _storeDetailsDao.getDetails(storeId); - String adminUsername = storeDetails.get(CloudianHyperStoreUtil.KEY_ADMIN_USER); - String adminPassword = storeDetails.get(CloudianHyperStoreUtil.KEY_ADMIN_PASS); - String strValidateSSL = storeDetails.get(CloudianHyperStoreUtil.KEY_ADMIN_VALIDATE_SSL); + 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); @@ -831,7 +831,7 @@ protected AmazonS3 getS3ClientByBucketAndStore(BucketTO bucket, long storeId) { if (bvo.getName().equals(bucket.getName())) { long accountId = bvo.getAccountId(); Map storeDetails = _storeDetailsDao.getDetails(storeId); - String s3url = storeDetails.get(CloudianHyperStoreUtil.KEY_S3_ENDPOINT_URL); + 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); @@ -868,7 +868,7 @@ protected AmazonS3 getS3Client(String s3url, String accessKey, String secretKey) */ protected AmazonIdentityManagement getIAMClientByStoreId(long storeId, CloudianCredential credential) { Map storeDetails = _storeDetailsDao.getDetails(storeId); - String iamUrl = storeDetails.get(CloudianHyperStoreUtil.KEY_IAM_ENDPOINT_URL); + 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 index 95f071ed26dc..d2fda09c65d1 100644 --- 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 @@ -71,22 +71,22 @@ public DataStore initialize(Map dsInfos) { objectStoreParameters.put(CloudianHyperStoreUtil.STORE_KEY_URL, url); objectStoreParameters.put(CloudianHyperStoreUtil.STORE_KEY_PROVIDER_NAME, providerName); - // Build the details map + // Pull out the details map @SuppressWarnings("unchecked") - Map details_from_gui = (Map) dsInfos.get(CloudianHyperStoreUtil.STORE_KEY_DETAILS); - if (details_from_gui == null) { + 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); } - // The Admin Username/Password are available respectively as accesskey/secretkey - String adminUsername = details_from_gui.get(CloudianHyperStoreUtil.GUI_DETAILS_KEY_ACCESSKEY); - String adminPassword = details_from_gui.get(CloudianHyperStoreUtil.GUI_DETAILS_KEY_SECRETKEY); - String validateSSL = details_from_gui.get(CloudianHyperStoreUtil.GUI_DETAILS_KEY_VALIDATE_SSL); + // 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_from_gui.get(CloudianHyperStoreUtil.GUI_DETAILS_KEY_S3_URL); - String iamUrl = details_from_gui.get(CloudianHyperStoreUtil.GUI_DETAILS_KEY_IAM_URL); + 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()); @@ -94,23 +94,18 @@ public DataStore initialize(Map dsInfos) { adminUsername, asteriskPassword, validateSSL, s3Url, iamUrl); throw new CloudRuntimeException("Required Cloudian HyperStore configuration parameters are missing/empty."); } - Map details = new HashMap(); - details.put(CloudianHyperStoreUtil.KEY_ADMIN_USER, adminUsername); - details.put(CloudianHyperStoreUtil.KEY_ADMIN_PASS, adminPassword); - details.put(CloudianHyperStoreUtil.KEY_ADMIN_VALIDATE_SSL, Boolean.toString(adminValidateSSL)); - details.put(CloudianHyperStoreUtil.KEY_S3_ENDPOINT_URL, s3Url); - details.put(CloudianHyperStoreUtil.KEY_IAM_ENDPOINT_URL, iamUrl); // 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(); - logger.info("Successfully connected to HyperStore: {}", version); // 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()); } 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 index 87e0c090bfb4..493e3a836e2b 100644 --- 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 @@ -46,21 +46,16 @@ public class CloudianHyperStoreUtil { public static final String STORE_KEY_NAME = "name"; public static final String STORE_KEY_DETAILS = "details"; - // GUI Object Store Details map key names - public static final String GUI_DETAILS_KEY_ACCESSKEY = "accesskey"; - public static final String GUI_DETAILS_KEY_SECRETKEY = "secretkey"; - public static final String GUI_DETAILS_KEY_VALIDATE_SSL = "validateSSL"; - public static final String GUI_DETAILS_KEY_S3_URL = "s3Url"; - public static final String GUI_DETAILS_KEY_IAM_URL = "iamUrl"; - - // detail map key names - public static final String KEY_ADMIN_USER = "hs_AdminUser"; - public static final String KEY_ADMIN_PASS = "hs_AdminPass"; - public static final String KEY_ADMIN_VALIDATE_SSL = "hs_AdminValidateSSL"; - public static final String KEY_S3_ENDPOINT_URL = "hs_S3EndpointURL"; + // 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_ENDPOINT_URL = "hs_IAMEndpointURL"; public static final String KEY_IAM_ACCESS_KEY = "hs_IAMAccessKey"; public static final String KEY_IAM_SECRET_KEY = "hs_IAMSecretKey"; 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 index e511105c7e6e..47749a749922 100644 --- 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 @@ -160,11 +160,11 @@ public void setUp() { // The StoreDetailMap has Endpoint info and Admin Credentials StoreDetailsMap = new HashMap(); - StoreDetailsMap.put(CloudianHyperStoreUtil.KEY_ADMIN_USER, TEST_ADMIN_USER_NAME); - StoreDetailsMap.put(CloudianHyperStoreUtil.KEY_ADMIN_PASS, TEST_ADMIN_PASSWORD); - StoreDetailsMap.put(CloudianHyperStoreUtil.KEY_ADMIN_VALIDATE_SSL, TEST_ADMIN_VALIDATE_SSL); - StoreDetailsMap.put(CloudianHyperStoreUtil.KEY_S3_ENDPOINT_URL, TEST_S3_URL); - StoreDetailsMap.put(CloudianHyperStoreUtil.KEY_IAM_ENDPOINT_URL, TEST_IAM_URL); + 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. 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 index b26cf199e17f..fe6dc16f3fc5 100644 --- 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 @@ -166,11 +166,11 @@ public void testInitializeValidation() { 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.KEY_ADMIN_USER)); - assertEquals(TEST_ADMIN_PASSWORD, updatedDetails.get(CloudianHyperStoreUtil.KEY_ADMIN_PASS)); - assertEquals(TEST_VALIDATE_SSL, updatedDetails.get(CloudianHyperStoreUtil.KEY_ADMIN_VALIDATE_SSL)); - assertEquals(TEST_S3_URL, updatedDetails.get(CloudianHyperStoreUtil.KEY_S3_ENDPOINT_URL)); - assertEquals(TEST_IAM_URL, updatedDetails.get(CloudianHyperStoreUtil.KEY_IAM_ENDPOINT_URL)); + 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 From 825d594cf4565f220603e6b516633807145e7143 Mon Sep 17 00:00:00 2001 From: Thomas O'Dowd Date: Fri, 18 Oct 2024 06:58:29 +0000 Subject: [PATCH 10/14] Update to address various review comments - error out of getUserBucketUsages() if bucket param is set but userid is not - added unit tests for the same. - split some copy/paste code into a new function - added unit tests for new function which required moving the test to the right package to test a protected function. - use the base class logger - misc tidy ups. --- .../cloudian/client/CloudianClient.java | 57 ++++++++++------ .../{ => client}/CloudianClientTest.java | 65 +++++++++++++++++-- ...oudianHyperStoreObjectStoreDriverImpl.java | 4 -- .../util/CloudianHyperStoreUtil.java | 53 +++++++-------- 4 files changed, 119 insertions(+), 60 deletions(-) rename plugins/integrations/cloudian/src/test/java/org/apache/cloudstack/cloudian/{ => client}/CloudianClientTest.java (92%) 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 ff5b4acffbd1..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,7 @@ 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; @@ -156,6 +157,30 @@ private void throwTimeoutOrServerException(final IOException 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; + } + } + private HttpResponse delete(final String path) throws IOException { final HttpResponse response = httpClient.execute(new HttpDelete(adminApiUrl + path), httpContext); checkAuthFailure(response); @@ -235,14 +260,17 @@ public String getServerVersion() { * * @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. + * @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)) { - return new ArrayList<>(); + 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); @@ -251,7 +279,6 @@ public List getUserBucketUsages(final String groupId, f cmd.append(userId); } if (! StringUtils.isBlank(bucket)) { - // Assume userId is also set (or request fails). cmd.append("&bucket="); cmd.append(bucket); } @@ -341,15 +368,10 @@ public List listUsers(final String groupId) { if (noResponseEntity(response)) { throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "API error"); } - // The empty list case is badly behaved and returns as 200 with an empty body. - // We'll try detect this by reading the first byte and then putting it back if required. - PushbackInputStream iStream = new PushbackInputStream(response.getEntity().getContent()); - int firstByte=iStream.read(); - if (firstByte == -1) { - return new ArrayList<>(); // EOF => empty list + InputStream iStream = getNonEmptyContentStream(response); + if (iStream == null) { + return new ArrayList<>(); // empty body => empty list } - // unread that first byte and process the JSON - iStream.unread(firstByte); final ObjectMapper mapper = new ObjectMapper(); return Arrays.asList(mapper.readValue(iStream, CloudianUser[].class)); } catch (final IOException e) { @@ -512,15 +534,10 @@ public List listGroups() { if (noResponseEntity(response)) { throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "API error"); } - // The empty list case is badly behaved and returns as 200 with an empty body. - // We'll try detect this by reading the first byte and then putting it back if required. - PushbackInputStream iStream = new PushbackInputStream(response.getEntity().getContent()); - int firstByte=iStream.read(); - if (firstByte == -1) { - return new ArrayList<>(); // EOF => empty list + InputStream iStream = getNonEmptyContentStream(response); + if (iStream == null) { + return new ArrayList<>(); // Empty body => empty list } - // unread that first byte and process the JSON - iStream.unread(firstByte); final ObjectMapper mapper = new ObjectMapper(); return Arrays.asList(mapper.readValue(iStream, CloudianGroup[].class)); } catch (final IOException e) { 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/client/CloudianClientTest.java similarity index 92% rename from plugins/integrations/cloudian/src/test/java/org/apache/cloudstack/cloudian/CloudianClientTest.java rename to plugins/integrations/cloudian/src/test/java/org/apache/cloudstack/cloudian/client/CloudianClientTest.java index 2aa30526f0f5..74474edc89b7 100644 --- a/plugins/integrations/cloudian/src/test/java/org/apache/cloudstack/cloudian/CloudianClientTest.java +++ b/plugins/integrations/cloudian/src/test/java/org/apache/cloudstack/cloudian/client/CloudianClientTest.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.cloudstack.cloudian; +package org.apache.cloudstack.cloudian.client; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.containing; @@ -31,26 +31,33 @@ 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.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.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; @@ -119,6 +126,38 @@ public void testBasicAuthFailure() { 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 /////////////////// ///////////////////////////////////////////////////// @@ -135,6 +174,20 @@ public void 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")) 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 index 3cbe40e03f6c..a74a29575633 100644 --- 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 @@ -41,8 +41,6 @@ import org.apache.cloudstack.storage.object.BaseObjectStoreDriverImpl; import org.apache.cloudstack.storage.object.Bucket; import org.apache.cloudstack.storage.object.BucketObject; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import com.amazonaws.AmazonClientException; import com.amazonaws.services.identitymanagement.AmazonIdentityManagement; @@ -82,8 +80,6 @@ import com.cloud.utils.exception.CloudRuntimeException; public class CloudianHyperStoreObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { - protected Logger logger = LogManager.getLogger(CloudianHyperStoreObjectStoreDriverImpl.class); - @Inject AccountDao _accountDao; 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 index 493e3a836e2b..e3b3996fcb20 100644 --- 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 @@ -64,33 +64,28 @@ public class CloudianHyperStoreUtil { public static final String IAM_USER_USERNAME = "CloudStack"; public static final String IAM_USER_POLICY_NAME = "CloudStackPolicy"; - public static final String IAM_USER_POLICY; - static { - StringBuilder sb = new StringBuilder(); - sb.append("{\n"); - sb.append(" \"Version\": \"2012-10-17\",\n"); - sb.append(" \"Statement\": [\n"); - sb.append(" {\n"); - sb.append(" \"Sid\": \"AllowFullS3Access\",\n"); - sb.append(" \"Effect\": \"Allow\",\n"); - sb.append(" \"Action\": [\n"); - sb.append(" \"s3:*\"\n"); - sb.append(" ],\n"); - sb.append(" \"Resource\": \"*\"\n"); - sb.append(" },\n"); - sb.append(" {\n"); - sb.append(" \"Sid\": \"ExceptBucketCreationOrDeletion\",\n"); - sb.append(" \"Effect\": \"Deny\",\n"); - sb.append(" \"Action\": [\n"); - sb.append(" \"s3:createBucket\",\n"); - sb.append(" \"s3:deleteBucket\"\n"); - sb.append(" ],\n"); - sb.append(" \"Resource\": \"*\"\n"); - sb.append(" }\n"); - sb.append(" ]\n"); - sb.append("}\n"); - IAM_USER_POLICY = sb.toString(); - } + 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. @@ -120,9 +115,7 @@ public static CloudianClient getCloudianClient(String url, String user, String p port = DEFAULT_ADMIN_PORT; } return new CloudianClient(host, port, scheme, user, pass, validateSSL, getAdminTimeoutSeconds()); - } catch (MalformedURLException e) { - throw new CloudRuntimeException(e); - } catch (KeyStoreException | NoSuchAlgorithmException | KeyManagementException e) { + } catch (MalformedURLException | KeyStoreException | NoSuchAlgorithmException | KeyManagementException e) { throw new CloudRuntimeException(e); } } From b1d3a8cdbd6badfccee698f5a058eaa982cd4a55 Mon Sep 17 00:00:00 2001 From: Thomas O'Dowd Date: Fri, 18 Oct 2024 07:46:26 +0000 Subject: [PATCH 11/14] Fix policy typo where an extra space crept in. --- .../storage/datastore/util/CloudianHyperStoreUtil.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index e3b3996fcb20..efa10229428c 100644 --- 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 @@ -80,7 +80,7 @@ public class CloudianHyperStoreUtil { " \"Effect\": \"Deny\",\n" + " \"Action\": [\n" + " \"s3:createBucket\",\n" + - " \"s3: deleteBucket\"\n" + + " \"s3:deleteBucket\"\n" + " ],\n" + " \"Resource\": \"*\"\n" + " }\n" + From 9078cea74948478a6ac461ac2742e39bff555a95 Mon Sep 17 00:00:00 2001 From: Thomas O'Dowd Date: Mon, 21 Oct 2024 02:52:04 +0000 Subject: [PATCH 12/14] Update based on review to replace duplicated code with a function - created new deleteIAMCredential() function. --- ...oudianHyperStoreObjectStoreDriverImpl.java | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) 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 index a74a29575633..ebc40c0c94fe 100644 --- 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 @@ -216,11 +216,7 @@ protected AccessKey createIAMCredentials(long storeId, Map detai // 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. - DeleteAccessKeyRequest deleteAccessKeyRequest = new DeleteAccessKeyRequest(); - deleteAccessKeyRequest.setUserName(iamUser); - deleteAccessKeyRequest.setAccessKeyId(accessKeyMetadata.getAccessKeyId()); - logger.info("Deleting un-managed IAM AccessKeyId {} for IAM User {}", accessKeyMetadata.getAccessKeyId(), iamUser); - iamClient.deleteAccessKey(deleteAccessKeyRequest); + deleteIAMCredential(iamClient, iamUser, accessKeyMetadata.getAccessKeyId()); } } catch (NoSuchEntityException e) { // No IAM User. Ignore and fix this below. @@ -246,11 +242,7 @@ protected AccessKey createIAMCredentials(long storeId, Map detai 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()) { - DeleteAccessKeyRequest deleteAccessKeyRequest = new DeleteAccessKeyRequest(); - deleteAccessKeyRequest.setUserName(iamUser); - deleteAccessKeyRequest.setAccessKeyId(accessKeyMetadata.getAccessKeyId()); - logger.info("Deleting un-managed IAM AccessKeyId {} for IAM User {}", accessKeyMetadata.getAccessKeyId(), iamUser); - iamClient.deleteAccessKey(deleteAccessKeyRequest); + deleteIAMCredential(iamClient, iamUser, accessKeyMetadata.getAccessKeyId()); } } @@ -260,6 +252,21 @@ protected AccessKey createIAMCredentials(long storeId, Map detai 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 From 7430c0c3385228bc7f6ee35062761bce31b5d78e Mon Sep 17 00:00:00 2001 From: Thomas O'Dowd Date: Wed, 30 Oct 2024 15:48:19 +0900 Subject: [PATCH 13/14] Delete the `add_cloudian_hyperstore.png` file, --- plugins/storage/object/cloudian/README.md | 28 ++++++++---------- .../cloudian/add_cloudian_hyperstore.png | Bin 138188 -> 0 bytes 2 files changed, 13 insertions(+), 15 deletions(-) delete mode 100644 plugins/storage/object/cloudian/add_cloudian_hyperstore.png diff --git a/plugins/storage/object/cloudian/README.md b/plugins/storage/object/cloudian/README.md index d9ee4c161c15..90d7bd632bf5 100644 --- a/plugins/storage/object/cloudian/README.md +++ b/plugins/storage/object/cloudian/README.md @@ -10,9 +10,9 @@ Cloudian HyperStore is a fully AWS-S3 compatible Object Storage solution. The fo | 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 | +| Admin | | 19443 | User Management etc. | +| S3 | 80 | 443 | AWS-S3 compatible API | +| IAM | 16080 | 16443 | AWS-IAM compatible API | ## Configuration @@ -40,9 +40,7 @@ Cloudian HyperStore is a fully AWS-S3 compatible Object Storage solution. The fo A new `Cloudian HyperStore` Object Store can be added by the CloudStack `admin` user via the UI -> Infrastructure -> Object Storage -> Add Object Storage button. -![Add Cloudian HyperStore Object Storage](add_cloudian_hyperstore.png) - -These configuration parameters are delivered to the LifeCycle class as a map with the following keys and values. +Once added, this passes various configuration parameters to the LifeCycle class as a map with the following keys and values. ```text DataStoreInfo MAP @@ -93,16 +91,16 @@ When a CloudStack Account user creates a bucket under their account for the firs 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.| +| 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 note worthy. +The following are noteworthy. ### Bucket Quota is Unsupported @@ -165,13 +163,13 @@ While a bucket is not visible to CloudStack, a 3rd party application using the s 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 of CloudStack control. +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. +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/add_cloudian_hyperstore.png b/plugins/storage/object/cloudian/add_cloudian_hyperstore.png deleted file mode 100644 index f281d002cbc663a66d149b6d36537dabcdf9a09d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 138188 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 z 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ξ6W%<_a2$10 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 From 01be768a6955313f6e5962b6939124a97e54481c Mon Sep 17 00:00:00 2001 From: Thomas O'Dowd Date: Wed, 26 Feb 2025 01:37:42 +0000 Subject: [PATCH 14/14] Update PR to work around the create bucket mandatory quota setting The CloudStack UI and API now require a quota setting to be configured on all buckets. HyperStore does not support a bucket quota limit setting. As a work around, this commit allows a quota setting of 0 to indicate there is no quota limit. Any other non-zero value will cause the quota setting to fail. HyperStore may add a bucket quota limit setting in a future version at which time we will address this further. Additionally fix pom to update version --- plugins/storage/object/cloudian/README.md | 8 +++---- plugins/storage/object/cloudian/pom.xml | 2 +- ...oudianHyperStoreObjectStoreDriverImpl.java | 22 ++++++++++++++----- ...anHyperStoreObjectStoreDriverImplTest.java | 13 ++++++++--- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/plugins/storage/object/cloudian/README.md b/plugins/storage/object/cloudian/README.md index 90d7bd632bf5..a0f68d7bc12c 100644 --- a/plugins/storage/object/cloudian/README.md +++ b/plugins/storage/object/cloudian/README.md @@ -20,12 +20,12 @@ Cloudian HyperStore is a fully AWS-S3 compatible Object Storage solution. The fo 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 not documented here. + 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 - hsh$ hsctl service restart --nodes=ALL + hsh$ hsctl config apply s3 cmc + hsh$ hsctl service restart s3 cmc --nodes=ALL ``` 2. The Admin API Username and Password @@ -104,7 +104,7 @@ The following are noteworthy. ### Bucket Quota is Unsupported -This operation is not supported by this plugin. Cloudian HyperStore does not currently support restricting the size of a bucket to a particular quota. +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 diff --git a/plugins/storage/object/cloudian/pom.xml b/plugins/storage/object/cloudian/pom.xml index 0efd4a0294eb..3d9a0c7ee8cd 100644 --- a/plugins/storage/object/cloudian/pom.xml +++ b/plugins/storage/object/cloudian/pom.xml @@ -24,7 +24,7 @@ org.apache.cloudstack cloudstack-plugins - 4.20.0.0-SNAPSHOT + 4.21.0.0-SNAPSHOT ../../../pom.xml 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 index ebc40c0c94fe..a0a717f52e42 100644 --- 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 @@ -728,17 +728,27 @@ public boolean deleteBucketVersioning(BucketTO bucket, long storeId) { } /** - * Set the bucket quota to size bytes. + * 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 GB (1000^3) size to set the quota to. - * @throws CloudRuntimeException is always thrown as Cloudian does not currently - * implement bucket quotas. + * @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) { - logger.warn("Unable to set quota for bucket \"{}\" to {}GB. Cloudian does not implement Bucket Quota.", bucket.getName(), size); - throw new CloudRuntimeException("This bucket does not support quotas."); + 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."); } /** 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 index 47749a749922..1cf99ca53465 100644 --- 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 @@ -556,11 +556,18 @@ public void testSetBucketPolicyPublic() throws Exception { } @Test(expected = CloudRuntimeException.class) - public void testSetBucketQuota() { + public void testSetBucketQuotaNonZero() { BucketTO bucket = mock(BucketTO.class); when(bucket.getName()).thenReturn(TEST_BUCKET_NAME); - // Quota is not implemented by HyperStore, we throw an CloudRuntimeException. - cloudianHyperStoreObjectStoreDriverImpl.setBucketQuota(bucket, TEST_STORE_ID, 5000L); + // 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