diff --git a/docs/src/main/asciidoc/spring-cloud-config.adoc b/docs/src/main/asciidoc/spring-cloud-config.adoc index eef8807b..9c33e3bc 100644 --- a/docs/src/main/asciidoc/spring-cloud-config.adoc +++ b/docs/src/main/asciidoc/spring-cloud-config.adoc @@ -337,7 +337,7 @@ To correct the above error the RSA key must be converted to PEM format. An examp Spring Cloud Config Server also supports https://docs.aws.amazon.com/codecommit/latest/userguide/welcome.html[AWS CodeCommit] authentication. AWS CodeCommit uses an authentication helper when using Git from the command line. This helper is not used with the JGit library, so a JGit CredentialProvider for AWS CodeCommit is created if the Git URI matches the AWS CodeCommit pattern. -AWS CodeCommit URIs follow this pattern: +AWS CodeCommit URIs follow this pattern: ```bash https//git-codecommit.${AWS_REGION}.amazonaws.com/v1/repos/${repo}. @@ -834,6 +834,18 @@ secret value = ---- +===== AWS Parameter Store + +When using AWS Parameter Store as a backend, you can share configuration with all applications by placing properties within the `/application` hierarchy. + +For example, if you add parameters with the following names, all applications using the config server will have the properties `foo.bar` and `fred.baz` available to them: + +[source] +---- +/config/application/foo.bar +/config/application-default/fred.baz +---- + ==== JDBC Backend Spring Cloud Config Server supports JDBC (relational database) as a backend for configuration properties. @@ -932,6 +944,99 @@ Configuration files are stored in your bucket as `{application}-{profile}.proper NOTE: When no profile is specified `default` will be used. +==== AWS Parameter Store Backend + +Spring Cloud Config Server supports AWS Parameter Store as a backend for configuration properties. You can enable this feature by adding a dependency to the link:https://github.com/aws/aws-sdk-java/tree/master/aws-java-sdk-ssm[AWS Java SDK for SSM]. + +[source,xml,indent=0] +.pom.xml +---- + + com.amazonaws + aws-java-sdk-ssm + +---- + +The following configuration uses the AWS SSM client to access parameters. + +[source,yaml] +---- +spring: + profiles: + active: awsparamstore + cloud: + config: + server: + awsparamstore: + region: eu-west-2 + endpoint: https://ssm.eu-west-2.amazonaws.com + origin: aws:parameter: + prefix: /config/service + profileSeparator: _ + recursive: true + decryptValues: true + maxResults: 5 +---- + +The following table describes the AWS Parameter Store configuration properties. + +.AWS Parameter Store Configuration Properties +|=== +|Property Name |Required |Default Value |Remarks + +|*region* +|no +| +|The region to be used by the AWS Parameter Store client. If it's not explicitly set, the SDK tries to determine the region to use by using the link:https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/java-dg-region-selection.html#default-region-provider-chain[Default Region Provider Chain]. + +|*endpoint* +|no +| +|The URL of the entry point for the AWS SSM client. This can be used to specify an alternate endpoint for the API requests. + +|*origin* +|no +|`aws:ssm:parameter:` +|The prefix that is added to the property source's name to show their provenance. + +|*prefix* +|no +|`/config` +|Prefix indicating L1 level in the parameter hierarchy for every property loaded from the AWS Parameter Store. + +|*profileSeparator* +|no +|`-` +|String that separates an appended profile from the context name. + +|*recursive* +|no +|`true` +|Flag to indicate the retrieval of all AWS parameters within a hierarchy. + +|*decryptValues* +|no +|`true` +|Flag to indicate the retrieval of all AWS parameters with their value decrypted. + +|*maxResults* +|no +|`10` +|The maximum number of items to return for an AWS Parameter Store API call. + +|=== + +AWS Parameter Store API credentials are determined using the link:https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html#credentials-default[Default Credential Provider Chain]. +Versioned parameters are already supported with the default behaviour of returning the latest version. + +[NOTE] +==== +- When no application is specified `application` is the default, and when no profile is specified `default` is used. +- Valid values for `awsparamstore.prefix` must start with a forward slash followed by one or more valid path segments or be empty. +- Valid values for `awsparamstore.profileSeparator` can only contain dots, dashes and underscores. +- Valid values for `awsparamstore.maxResults` must be within the *[1, 10]* range. +==== + ==== AWS Secrets Manager Backend Spring Cloud Config Server supports link:https://aws.amazon.com/secrets-manager/[AWS Secrets Manager] as a backend for configuration properties. diff --git a/pom.xml b/pom.xml index af56ee71..a5ca4c64 100644 --- a/pom.xml +++ b/pom.xml @@ -28,7 +28,7 @@ config 3.1.0-SNAPSHOT - 1.11.903 + 1.11.911 v1-rev20201112-1.30.10 true true @@ -83,6 +83,11 @@ aws-java-sdk-secretsmanager ${aws-java-sdk.version} + + com.amazonaws + aws-java-sdk-ssm + ${aws-java-sdk.version} + com.google.apis google-api-services-iam diff --git a/spring-cloud-config-server/pom.xml b/spring-cloud-config-server/pom.xml index dcd9d737..2117add4 100644 --- a/spring-cloud-config-server/pom.xml +++ b/spring-cloud-config-server/pom.xml @@ -104,6 +104,11 @@ aws-java-sdk-secretsmanager true + + com.amazonaws + aws-java-sdk-ssm + true + org.springframework.boot spring-boot-autoconfigure-processor diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/config/EnvironmentRepositoryConfiguration.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/config/EnvironmentRepositoryConfiguration.java index 2ad72105..a1692f5f 100644 --- a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/config/EnvironmentRepositoryConfiguration.java +++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/config/EnvironmentRepositoryConfiguration.java @@ -23,6 +23,7 @@ import javax.servlet.http.HttpServletRequest; import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.secretsmanager.AWSSecretsManager; +import com.amazonaws.services.simplesystemsmanagement.AWSSimpleSystemsManagement; import com.google.cloud.secretmanager.v1.SecretManagerServiceClient; import org.apache.http.client.HttpClient; import org.eclipse.jgit.api.TransportConfigCallback; @@ -40,6 +41,9 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.cloud.config.server.composite.CompositeEnvironmentBeanFactoryPostProcessor; import org.springframework.cloud.config.server.composite.ConditionalOnMissingSearchPathLocator; import org.springframework.cloud.config.server.composite.ConditionalOnSearchPathLocator; +import org.springframework.cloud.config.server.environment.AwsParameterStoreEnvironmentProperties; +import org.springframework.cloud.config.server.environment.AwsParameterStoreEnvironmentRepository; +import org.springframework.cloud.config.server.environment.AwsParameterStoreEnvironmentRepositoryFactory; import org.springframework.cloud.config.server.environment.AwsS3EnvironmentProperties; import org.springframework.cloud.config.server.environment.AwsS3EnvironmentRepository; import org.springframework.cloud.config.server.environment.AwsS3EnvironmentRepositoryFactory; @@ -106,17 +110,19 @@ import org.springframework.vault.core.VaultTemplate; * @author Alberto C. RĂ­os * @author Scott Frederick * @author Tejas Pandilwar + * @author Iulian Antohe */ @Configuration(proxyBeanMethods = false) @EnableConfigurationProperties({ SvnKitEnvironmentProperties.class, CredhubEnvironmentProperties.class, JdbcEnvironmentProperties.class, NativeEnvironmentProperties.class, VaultEnvironmentProperties.class, RedisEnvironmentProperties.class, AwsS3EnvironmentProperties.class, - AwsSecretsManagerEnvironmentProperties.class, GoogleSecretManagerEnvironmentProperties.class }) + AwsSecretsManagerEnvironmentProperties.class, AwsParameterStoreEnvironmentProperties.class, GoogleSecretManagerEnvironmentProperties.class }) @Import({ CompositeRepositoryConfiguration.class, JdbcRepositoryConfiguration.class, VaultConfiguration.class, VaultRepositoryConfiguration.class, SpringVaultRepositoryConfiguration.class, CredhubConfiguration.class, CredhubRepositoryConfiguration.class, SvnRepositoryConfiguration.class, NativeRepositoryConfiguration.class, GitRepositoryConfiguration.class, RedisRepositoryConfiguration.class, GoogleCloudSourceConfiguration.class, AwsS3RepositoryConfiguration.class, AwsSecretsManagerRepositoryConfiguration.class, + AwsParameterStoreRepositoryConfiguration.class,, GoogleSecretManagerRepositoryConfiguration.class, DefaultRepositoryConfiguration.class }) public class EnvironmentRepositoryConfiguration { @@ -225,6 +231,18 @@ public class EnvironmentRepositoryConfiguration { } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(AWSSimpleSystemsManagement.class) + static class AwsParameterStoreFactoryConfig { + + @Bean + public AwsParameterStoreEnvironmentRepositoryFactory awsParameterStoreEnvironmentRepositoryFactory( + ConfigServerProperties server) { + return new AwsParameterStoreEnvironmentRepositoryFactory(server); + } + + } + @Configuration(proxyBeanMethods = false) @ConditionalOnClass(SVNException.class) static class SvnFactoryConfig { @@ -385,6 +403,20 @@ class AwsS3RepositoryConfiguration { } +@Configuration(proxyBeanMethods = false) +@Profile("awsparamstore") +class AwsParameterStoreRepositoryConfiguration { + + @Bean + @ConditionalOnMissingBean(AwsParameterStoreEnvironmentRepository.class) + public AwsParameterStoreEnvironmentRepository awsParameterStoreEnvironmentRepository( + AwsParameterStoreEnvironmentRepositoryFactory factory, + AwsParameterStoreEnvironmentProperties environmentProperties) { + return factory.build(environmentProperties); + } + +} + @Configuration(proxyBeanMethods = false) @Profile("awssecretsmanager") class AwsSecretsManagerRepositoryConfiguration { diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsParameterStoreEnvironmentProperties.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsParameterStoreEnvironmentProperties.java new file mode 100644 index 00000000..c2768513 --- /dev/null +++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsParameterStoreEnvironmentProperties.java @@ -0,0 +1,178 @@ +/* + * Copyright 2018-2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.config.server.environment; + +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.config.server.support.EnvironmentRepositoryProperties; +import org.springframework.core.Ordered; +import org.springframework.validation.annotation.Validated; + +/** + * @author Iulian Antohe + */ +@Validated +@ConfigurationProperties("spring.cloud.config.server.awsparamstore") +public class AwsParameterStoreEnvironmentProperties + implements EnvironmentRepositoryProperties { + + static final String DEFAULT_PATH_SEPARATOR = "/"; + + private static final String DEFAULT_ORIGIN = "aws:ssm:parameter:"; + + private static final String DEFAULT_PREFIX = DEFAULT_PATH_SEPARATOR + "config"; + + private static final String DEFAULT_PROFILE_SEPARATOR = "-"; + + /** + * The order of the environment repository. + */ + private int order = Ordered.LOWEST_PRECEDENCE; + + /** + * The region to be used by the AWS Parameter Store client. + */ + private String region; + + /** + * The service endpoint to be used by the AWS Parameter Store client. + */ + private String endpoint; + + /** + * Prefix indicating the property's origin. Defaults to "aws:ssm:parameter:". + */ + @NotNull + private String origin = DEFAULT_ORIGIN; + + /** + * Prefix indicating first level for every property loaded from the AWS Parameter + * Store. Value must start with a forward slash followed by one or more valid path + * segments or be empty. Defaults to "/config". + */ + @NotNull + @Pattern(regexp = "(/[a-zA-Z0-9.\\-_]+)*") + private String prefix = DEFAULT_PREFIX; + + /** + * String that separates an appended profile from the context name. Note that an AWS + * parameter name can only contain dots, dashes and underscores next to alphanumeric + * characters. Defaults to "-". + */ + @NotBlank + @Pattern(regexp = "[a-zA-Z0-9.\\-_/]+") + private String profileSeparator = DEFAULT_PROFILE_SEPARATOR; + + /** + * Flag to indicate the retrieval of all AWS parameters within a hierarchy. Defaults + * to "true". + */ + private boolean recursive = true; + + /** + * Flag to indicate the retrieval of all AWS parameters in a hierarchy with their + * value decrypted. Defaults to "true". + */ + private boolean decryptValues = true; + + /** + * The maximum number of items to return for an AWS Parameter Store API call. Defaults + * to "10". + */ + @Min(1) + @Max(10) + private int maxResults = 10; + + public int getOrder() { + return order; + } + + @Override + public void setOrder(int order) { + this.order = order; + } + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getOrigin() { + return origin; + } + + public void setOrigin(String origin) { + this.origin = origin; + } + + public String getPrefix() { + return prefix; + } + + public void setPrefix(String prefix) { + this.prefix = prefix; + } + + public String getProfileSeparator() { + return profileSeparator; + } + + public void setProfileSeparator(String profileSeparator) { + this.profileSeparator = profileSeparator; + } + + public boolean isRecursive() { + return recursive; + } + + public void setRecursive(boolean recursive) { + this.recursive = recursive; + } + + public boolean isDecryptValues() { + return decryptValues; + } + + public void setDecryptValues(boolean decryptValues) { + this.decryptValues = decryptValues; + } + + public int getMaxResults() { + return maxResults; + } + + public void setMaxResults(int maxResults) { + this.maxResults = maxResults; + } + +} diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsParameterStoreEnvironmentRepository.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsParameterStoreEnvironmentRepository.java new file mode 100644 index 00000000..c454291f --- /dev/null +++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsParameterStoreEnvironmentRepository.java @@ -0,0 +1,174 @@ +/* + * Copyright 2013-2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.config.server.environment; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.amazonaws.services.simplesystemsmanagement.AWSSimpleSystemsManagement; +import com.amazonaws.services.simplesystemsmanagement.model.GetParametersByPathRequest; +import com.amazonaws.services.simplesystemsmanagement.model.GetParametersByPathResult; +import com.amazonaws.services.simplesystemsmanagement.model.Parameter; + +import org.springframework.cloud.config.environment.Environment; +import org.springframework.cloud.config.environment.PropertySource; +import org.springframework.cloud.config.server.config.ConfigServerProperties; +import org.springframework.util.StringUtils; + +import static org.springframework.cloud.config.server.environment.AwsParameterStoreEnvironmentProperties.DEFAULT_PATH_SEPARATOR; + +/** + * @author Iulian Antohe + */ +public class AwsParameterStoreEnvironmentRepository implements EnvironmentRepository { + + private final AWSSimpleSystemsManagement awsSsmClient; + + private final ConfigServerProperties configServerProperties; + + private final AwsParameterStoreEnvironmentProperties environmentProperties; + + public AwsParameterStoreEnvironmentRepository(AWSSimpleSystemsManagement awsSsmClient, + ConfigServerProperties configServerProperties, + AwsParameterStoreEnvironmentProperties environmentProperties) { + this.awsSsmClient = awsSsmClient; + this.configServerProperties = configServerProperties; + this.environmentProperties = environmentProperties; + } + + @Override + public Environment findOne(String application, String profile, String label) { + if (!StringUtils.hasLength(application)) { + application = configServerProperties.getDefaultApplicationName(); + } + + if (!StringUtils.hasLength(profile)) { + profile = configServerProperties.getDefaultProfile(); + } + + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + Environment result = new Environment(application, profiles, label, null, null); + + Set paths = buildParameterPaths(application, profiles); + List propertySources = getPropertySources(paths); + + result.addAll(propertySources); + + return result; + } + + private Set buildParameterPaths(String application, String[] profiles) { + Set result = new LinkedHashSet<>(); + + String prefix = environmentProperties.getPrefix(); + String defaultApplication = configServerProperties.getDefaultApplicationName(); + String profileSeparator = environmentProperties.getProfileSeparator(); + String defaultProfile = configServerProperties.getDefaultProfile(); + + List orderedProfiles = Stream + .concat(Arrays.stream(profiles).filter(p -> !p.equals(defaultProfile)), + Arrays.stream(new String[] { defaultProfile })) + .collect(Collectors.toList()); + + if (application.equals(defaultApplication)) { + for (String profile : orderedProfiles) { + result.add(prefix + DEFAULT_PATH_SEPARATOR + defaultApplication + + profileSeparator + profile + DEFAULT_PATH_SEPARATOR); + } + } + else { + for (String profile : orderedProfiles) { + result.add(prefix + DEFAULT_PATH_SEPARATOR + application + + profileSeparator + profile + DEFAULT_PATH_SEPARATOR); + + result.add(prefix + DEFAULT_PATH_SEPARATOR + defaultApplication + + profileSeparator + profile + DEFAULT_PATH_SEPARATOR); + } + + result.add(prefix + DEFAULT_PATH_SEPARATOR + application + + DEFAULT_PATH_SEPARATOR); + } + + result.add(prefix + DEFAULT_PATH_SEPARATOR + defaultApplication + + DEFAULT_PATH_SEPARATOR); + + return result; + } + + private List getPropertySources(Set parameterPaths) { + List result = new ArrayList<>(); + + for (String path : parameterPaths) { + String name = environmentProperties.getOrigin() + path; + Map source = getPropertiesByParameterPath(path); + + if (!source.isEmpty()) { + result.add(new PropertySource(name, source)); + } + } + + Map overrides = configServerProperties.getOverrides(); + + if (!overrides.isEmpty()) { + result.add(0, new PropertySource("overrides", overrides)); + } + + return result; + } + + private Map getPropertiesByParameterPath(String path) { + Map result = new HashMap<>(); + + GetParametersByPathRequest request = new GetParametersByPathRequest() + .withPath(path).withRecursive(environmentProperties.isRecursive()) + .withWithDecryption(environmentProperties.isDecryptValues()) + .withMaxResults(environmentProperties.getMaxResults()); + + GetParametersByPathResult response = awsSsmClient.getParametersByPath(request); + + if (response != null) { + addParametersToProperties(path, response.getParameters(), result); + + while (StringUtils.hasLength(response.getNextToken())) { + response = awsSsmClient.getParametersByPath(request.withNextToken(response.getNextToken())); + + addParametersToProperties(path, response.getParameters(), result); + } + } + + return result; + } + + private void addParametersToProperties(String path, List parameters, + Map properties) { + for (Parameter parameter : parameters) { + String name = StringUtils.delete(parameter.getName(), path) + .replace(DEFAULT_PATH_SEPARATOR, "."); + + properties.put(name, parameter.getValue()); + } + } + +} diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsParameterStoreEnvironmentRepositoryFactory.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsParameterStoreEnvironmentRepositoryFactory.java new file mode 100644 index 00000000..9ee52d91 --- /dev/null +++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsParameterStoreEnvironmentRepositoryFactory.java @@ -0,0 +1,69 @@ +/* + * Copyright 2018-2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.config.server.environment; + +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.regions.Regions; +import com.amazonaws.services.simplesystemsmanagement.AWSSimpleSystemsManagement; +import com.amazonaws.services.simplesystemsmanagement.AWSSimpleSystemsManagementClientBuilder; + +import org.springframework.cloud.config.server.config.ConfigServerProperties; +import org.springframework.util.StringUtils; + +/** + * @author Iulian Antohe + */ +public class AwsParameterStoreEnvironmentRepositoryFactory implements + EnvironmentRepositoryFactory { + + private final ConfigServerProperties configServerProperties; + + public AwsParameterStoreEnvironmentRepositoryFactory( + ConfigServerProperties configServerProperties) { + this.configServerProperties = configServerProperties; + } + + @Override + public AwsParameterStoreEnvironmentRepository build( + AwsParameterStoreEnvironmentProperties environmentProperties) { + AWSSimpleSystemsManagementClientBuilder clientBuilder = AWSSimpleSystemsManagementClientBuilder + .standard(); + + String region = environmentProperties.getRegion(); + + if (StringUtils.hasLength(region)) { + Regions awsRegion = Regions.fromName(region); + + clientBuilder.withRegion(awsRegion); + + String endpoint = environmentProperties.getEndpoint(); + + if (StringUtils.hasLength(endpoint)) { + AwsClientBuilder.EndpointConfiguration endpointConfiguration = new AwsClientBuilder.EndpointConfiguration( + endpoint, awsRegion.getName()); + + clientBuilder.withEndpointConfiguration(endpointConfiguration); + } + } + + AWSSimpleSystemsManagement client = clientBuilder.build(); + + return new AwsParameterStoreEnvironmentRepository(client, configServerProperties, + environmentProperties); + } + +} diff --git a/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/AdhocTestSuite.java b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/AdhocTestSuite.java index 0c434be3..025e1aeb 100644 --- a/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/AdhocTestSuite.java +++ b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/AdhocTestSuite.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2019 the original author or authors. + * Copyright 2013-2020 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,6 +32,7 @@ import org.springframework.cloud.config.server.encryption.EncryptionControllerTe import org.springframework.cloud.config.server.encryption.EncryptionIntegrationTests; import org.springframework.cloud.config.server.encryption.EnvironmentPrefixHelperTests; import org.springframework.cloud.config.server.encryption.KeyStoreTextEncryptorLocatorTests; +import org.springframework.cloud.config.server.environment.AwsParameterStoreEnvironmentRepositoryTests; import org.springframework.cloud.config.server.environment.AwsS3EnvironmentRepositoryTests; import org.springframework.cloud.config.server.environment.CompositeEnvironmentRepositoryTests; import org.springframework.cloud.config.server.environment.EnvironmentControllerIntegrationTests; @@ -86,7 +87,7 @@ import org.springframework.cloud.config.server.ssh.SshUriPropertyProcessorTest; SshPropertyValidatorTest.class, CompositeIntegrationTests.class, SubversionConfigServerIntegrationTests.class, ConfigServerHealthIndicatorTests.class, CustomCompositeEnvironmentRepositoryTests.class, CustomEnvironmentRepositoryTests.class, BootstrapConfigServerIntegrationTests.class, - AwsS3EnvironmentRepositoryTests.class }) + AwsS3EnvironmentRepositoryTests.class, AwsParameterStoreEnvironmentRepositoryTests.class }) @Ignore public class AdhocTestSuite { diff --git a/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/AwsParameterStoreEnvironmentRepositoryTests.java b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/AwsParameterStoreEnvironmentRepositoryTests.java new file mode 100644 index 00000000..7c0efed7 --- /dev/null +++ b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/AwsParameterStoreEnvironmentRepositoryTests.java @@ -0,0 +1,856 @@ +/* + * Copyright 2013-2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.config.server.environment; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.stream.Collector; +import java.util.stream.Collectors; + +import com.amazonaws.services.simplesystemsmanagement.AWSSimpleSystemsManagement; +import com.amazonaws.services.simplesystemsmanagement.model.GetParametersByPathRequest; +import com.amazonaws.services.simplesystemsmanagement.model.GetParametersByPathResult; +import com.amazonaws.services.simplesystemsmanagement.model.Parameter; +import com.amazonaws.services.simplesystemsmanagement.model.ParameterType; +import org.apache.commons.lang3.RandomUtils; +import org.junit.Test; + +import org.springframework.cloud.config.environment.Environment; +import org.springframework.cloud.config.environment.PropertySource; +import org.springframework.cloud.config.server.config.ConfigServerProperties; +import org.springframework.util.StringUtils; + +import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.cloud.config.server.environment.AwsParameterStoreEnvironmentProperties.DEFAULT_PATH_SEPARATOR; + +/** + * @author Iulian Antohe + */ +public class AwsParameterStoreEnvironmentRepositoryTests { + + private static final Map SHARED_PROPERTIES = new HashMap() { + { + put("logging.level.root", "warn"); + put("spring.cache.redis.time-to-live", "0"); + } + }; + + private static final Map SHARED_DEFAULT_PROPERTIES = new HashMap() { + { + put("logging.level.root", "error"); + put("spring.cache.redis.time-to-live", "1000"); + } + }; + + private static final Map SHARED_PRODUCTION_PROPERTIES = new HashMap() { + { + put("logging.level.root", "fatal"); + put("spring.cache.redis.time-to-live", "5000"); + } + }; + + private static final Map APPLICATION_SPECIFIC_PROPERTIES = new HashMap() { + { + put("logging.level.com.example.service", "trace"); + put("spring.cache.redis.time-to-live", "30000"); + } + }; + + private static final Map APPLICATION_SPECIFIC_DEFAULT_PROPERTIES = new HashMap() { + { + put("logging.level.com.example.service", "debug"); + put("spring.cache.redis.time-to-live", "60000"); + } + }; + + private static final Map APPLICATION_SPECIFIC_PRODUCTION_PROPERTIES = new HashMap() { + { + put("logging.level.com.example.service", "info"); + put("spring.cache.redis.time-to-live", "300000"); + } + }; + + private final AWSSimpleSystemsManagement awsSsmClientMock = mock( + AWSSimpleSystemsManagement.class, "aws-ssm-client-mock"); + + private final ConfigServerProperties configServerProperties = new ConfigServerProperties(); + + private final AwsParameterStoreEnvironmentProperties environmentProperties = new AwsParameterStoreEnvironmentProperties(); + + private final AwsParameterStoreEnvironmentRepository repository = new AwsParameterStoreEnvironmentRepository( + awsSsmClientMock, configServerProperties, environmentProperties); + + @Test + @SuppressWarnings("ConstantConditions") + public void testFindOneWithNullApplicationAndNullProfile() { + // Arrange + String application = null; + String profile = null; + String defaultApp = configServerProperties.getDefaultApplicationName(); + String defaultProfile = configServerProperties.getDefaultProfile(); + String[] profiles = StringUtils.commaDelimitedListToStringArray(defaultProfile); + + String sharedDefaultParamsPsName = "aws:ssm:parameter:/config/application-default/"; + PropertySource sharedDefaultParamsPs = new PropertySource( + sharedDefaultParamsPsName, SHARED_DEFAULT_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(defaultApp, profiles, null, null, null); + expected.addAll(Arrays.asList(sharedDefaultParamsPs, sharedParamsPs)); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + @SuppressWarnings("ConstantConditions") + public void testFindOneWithNullApplicationAndDefaultProfile() { + // Arrange + String application = null; + String profile = configServerProperties.getDefaultProfile(); + String defaultApp = configServerProperties.getDefaultApplicationName(); + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + String sharedDefaultParamsPsName = "aws:ssm:parameter:/config/application-default/"; + PropertySource sharedDefaultParamsPs = new PropertySource( + sharedDefaultParamsPsName, SHARED_DEFAULT_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(defaultApp, profiles, null, null, null); + expected.addAll(Arrays.asList(sharedDefaultParamsPs, sharedParamsPs)); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + @SuppressWarnings("ConstantConditions") + public void testFindOneWithNullApplicationAndNonExistentProfile() { + // Arrange + String application = null; + String profile = randomAlphabetic(RandomUtils.nextInt(3, 33)); + String defaultApp = configServerProperties.getDefaultApplicationName(); + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + String name = "aws:ssm:parameter:/config/application/"; + PropertySource ps = new PropertySource(name, SHARED_PROPERTIES); + + Environment expected = new Environment(defaultApp, profiles, null, null, null); + expected.add(ps); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + @SuppressWarnings("ConstantConditions") + public void testFindOneWithNullApplicationAndExistentProfile() { + // Arrange + String application = null; + String profile = "production"; + String defaultApp = configServerProperties.getDefaultApplicationName(); + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + String sharedProdParamsPsName = "aws:ssm:parameter:/config/application-production/"; + PropertySource sharedProdParamsPs = new PropertySource(sharedProdParamsPsName, + SHARED_PRODUCTION_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(defaultApp, profiles, null, null, null); + expected.addAll(Arrays.asList(sharedProdParamsPs, sharedParamsPs)); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + @SuppressWarnings("ConstantConditions") + public void testFindOneWithDefaultApplicationAndNullProfile() { + // Arrange + String application = configServerProperties.getDefaultApplicationName(); + String profile = null; + String defaultProfile = configServerProperties.getDefaultProfile(); + String[] profiles = StringUtils.commaDelimitedListToStringArray(defaultProfile); + + String sharedDefaultParamsPsName = "aws:ssm:parameter:/config/application-default/"; + PropertySource sharedDefaultParamsPs = new PropertySource( + sharedDefaultParamsPsName, SHARED_DEFAULT_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(application, profiles, null, null, null); + expected.addAll(Arrays.asList(sharedDefaultParamsPs, sharedParamsPs)); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + public void testFindOneWithDefaultApplicationAndDefaultProfile() { + // Arrange + String application = configServerProperties.getDefaultApplicationName(); + String profile = configServerProperties.getDefaultProfile(); + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + String sharedDefaultParamsPsName = "aws:ssm:parameter:/config/application-default/"; + PropertySource sharedDefaultParamsPs = new PropertySource( + sharedDefaultParamsPsName, SHARED_DEFAULT_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(application, profiles, null, null, null); + expected.addAll(Arrays.asList(sharedDefaultParamsPs, sharedParamsPs)); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + public void testFindOneWithDefaultApplicationAndNonExistentProfile() { + // Arrange + String application = configServerProperties.getDefaultApplicationName(); + String profile = randomAlphabetic(RandomUtils.nextInt(3, 33)); + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + String name = "aws:ssm:parameter:/config/application/"; + PropertySource ps = new PropertySource(name, SHARED_PROPERTIES); + + Environment expected = new Environment(application, profiles, null, null, null); + expected.add(ps); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + public void testFindOneWithDefaultApplicationAndExistentProfile() { + // Arrange + String application = configServerProperties.getDefaultApplicationName(); + String profile = "production"; + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + String sharedProdParamsPsName = "aws:ssm:parameter:/config/application-production/"; + PropertySource sharedProdParamsPs = new PropertySource(sharedProdParamsPsName, + SHARED_PRODUCTION_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(application, profiles, null, null, null); + expected.addAll(Arrays.asList(sharedProdParamsPs, sharedParamsPs)); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + @SuppressWarnings("ConstantConditions") + public void testFindOneWithNonExistentApplicationAndNullProfile() { + // Arrange + String application = randomAlphabetic(RandomUtils.nextInt(3, 33)); + String profile = null; + String defaultProfile = configServerProperties.getDefaultProfile(); + String[] profiles = StringUtils.commaDelimitedListToStringArray(defaultProfile); + + String sharedDefaultParamsPsName = "aws:ssm:parameter:/config/application-default/"; + PropertySource sharedDefaultParamsPs = new PropertySource( + sharedDefaultParamsPsName, SHARED_DEFAULT_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(application, profiles, null, null, null); + expected.addAll(Arrays.asList(sharedDefaultParamsPs, sharedParamsPs)); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + public void testFindOneWithNonExistentApplicationAndDefaultProfile() { + // Arrange + String application = randomAlphabetic(RandomUtils.nextInt(3, 33)); + String profile = configServerProperties.getDefaultProfile(); + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + String sharedDefaultParamsPsName = "aws:ssm:parameter:/config/application-default/"; + PropertySource sharedDefaultParamsPs = new PropertySource( + sharedDefaultParamsPsName, SHARED_DEFAULT_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(application, profiles, null, null, null); + expected.addAll(Arrays.asList(sharedDefaultParamsPs, sharedParamsPs)); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + public void testFindOneWithNonExistentApplicationAndNonExistentProfile() { + // Arrange + String application = randomAlphabetic(RandomUtils.nextInt(3, 33)); + String profile = randomAlphabetic(RandomUtils.nextInt(3, 33)); + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + String name = "aws:ssm:parameter:/config/application/"; + PropertySource ps = new PropertySource(name, SHARED_PROPERTIES); + + Environment expected = new Environment(application, profiles, null, null, null); + expected.add(ps); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + public void testFindOneWithNonExistentApplicationAndExistentProfile() { + // Arrange + String application = randomAlphabetic(RandomUtils.nextInt(3, 33)); + String profile = "production"; + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + String sharedProdParamsPsName = "aws:ssm:parameter:/config/application-production/"; + PropertySource sharedProdParamsPs = new PropertySource(sharedProdParamsPsName, + SHARED_PRODUCTION_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(application, profiles, null, null, null); + expected.addAll(Arrays.asList(sharedProdParamsPs, sharedParamsPs)); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + @SuppressWarnings("ConstantConditions") + public void testFindOneWithExistentApplicationAndNullProfile() { + // Arrange + String application = "service"; + String profile = null; + String defaultProfile = configServerProperties.getDefaultProfile(); + String[] profiles = StringUtils.commaDelimitedListToStringArray(defaultProfile); + + String appSpecificDefaultParamsPsName = "aws:ssm:parameter:/config/service-default/"; + PropertySource appSpecificDefaultParamsPs = new PropertySource( + appSpecificDefaultParamsPsName, APPLICATION_SPECIFIC_DEFAULT_PROPERTIES); + + String sharedDefaultParamsPsName = "aws:ssm:parameter:/config/application-default/"; + PropertySource sharedDefaultParamsPs = new PropertySource( + sharedDefaultParamsPsName, SHARED_DEFAULT_PROPERTIES); + + String appSpecificParamsPsName = "aws:ssm:parameter:/config/service/"; + PropertySource appSpecificParamsPs = new PropertySource(appSpecificParamsPsName, + APPLICATION_SPECIFIC_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(application, profiles, null, null, null); + + expected.addAll(Arrays.asList(appSpecificDefaultParamsPs, sharedDefaultParamsPs, + appSpecificParamsPs, sharedParamsPs)); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + public void testFindOneWithExistentApplicationAndDefaultProfile() { + // Arrange + String application = "service"; + String profile = configServerProperties.getDefaultProfile(); + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + String appSpecificDefaultParamsPsName = "aws:ssm:parameter:/config/service-default/"; + PropertySource appSpecificDefaultParamsPs = new PropertySource( + appSpecificDefaultParamsPsName, APPLICATION_SPECIFIC_DEFAULT_PROPERTIES); + + String sharedDefaultParamsPsName = "aws:ssm:parameter:/config/application-default/"; + PropertySource sharedDefaultParamsPs = new PropertySource( + sharedDefaultParamsPsName, SHARED_DEFAULT_PROPERTIES); + + String appSpecificParamsPsName = "aws:ssm:parameter:/config/service/"; + PropertySource appSpecificParamsPs = new PropertySource(appSpecificParamsPsName, + APPLICATION_SPECIFIC_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(application, profiles, null, null, null); + + expected.addAll(Arrays.asList(appSpecificDefaultParamsPs, sharedDefaultParamsPs, + appSpecificParamsPs, sharedParamsPs)); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + public void testFindOneWithExistentApplicationAndNonExistentProfile() { + // Arrange + String application = "service"; + String profile = randomAlphabetic(RandomUtils.nextInt(3, 33)); + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + String appSpecificParamsPsName = "aws:ssm:parameter:/config/service/"; + PropertySource appSpecificParamsPs = new PropertySource(appSpecificParamsPsName, + APPLICATION_SPECIFIC_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(application, profiles, null, null, null); + expected.addAll(Arrays.asList(appSpecificParamsPs, sharedParamsPs)); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + public void testFindOneWithExistentApplicationAndExistentProfile() { + // Arrange + String application = "service"; + String profile = "production"; + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + String appSpecificProdParamsPsName = "aws:ssm:parameter:/config/service-production/"; + PropertySource appSpecificProdParamsPs = new PropertySource( + appSpecificProdParamsPsName, APPLICATION_SPECIFIC_PRODUCTION_PROPERTIES); + + String sharedProdParamsPsName = "aws:ssm:parameter:/config/application-production/"; + PropertySource sharedProdParamsPs = new PropertySource(sharedProdParamsPsName, + SHARED_PRODUCTION_PROPERTIES); + + String appSpecificParamsPsName = "aws:ssm:parameter:/config/service/"; + PropertySource appSpecificParamsPs = new PropertySource(appSpecificParamsPsName, + APPLICATION_SPECIFIC_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(application, profiles, null, null, null); + + expected.addAll(Arrays.asList(appSpecificProdParamsPs, sharedProdParamsPs, + appSpecificParamsPs, sharedParamsPs)); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + public void testFindOneWithExistentApplicationAndMultipleExistentProfiles() { + // Arrange + String application = "service"; + String profile = configServerProperties.getDefaultProfile() + ",production"; + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + String appSpecificProdParamsPsName = "aws:ssm:parameter:/config/service-production/"; + PropertySource appSpecificProdParamsPs = new PropertySource( + appSpecificProdParamsPsName, APPLICATION_SPECIFIC_PRODUCTION_PROPERTIES); + + String sharedProdParamsPsName = "aws:ssm:parameter:/config/application-production/"; + PropertySource sharedProdParamsPs = new PropertySource(sharedProdParamsPsName, + SHARED_PRODUCTION_PROPERTIES); + + String appSpecificDefaultParamsPsName = "aws:ssm:parameter:/config/service-default/"; + PropertySource appSpecificDefaultParamsPs = new PropertySource( + appSpecificDefaultParamsPsName, APPLICATION_SPECIFIC_DEFAULT_PROPERTIES); + + String sharedDefaultParamsPsName = "aws:ssm:parameter:/config/application-default/"; + PropertySource sharedDefaultParamsPs = new PropertySource( + sharedDefaultParamsPsName, SHARED_DEFAULT_PROPERTIES); + + String appSpecificParamsPsName = "aws:ssm:parameter:/config/service/"; + PropertySource appSpecificParamsPs = new PropertySource(appSpecificParamsPsName, + APPLICATION_SPECIFIC_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(application, profiles, null, null, null); + + expected.addAll(Arrays.asList(appSpecificProdParamsPs, sharedProdParamsPs, + appSpecificDefaultParamsPs, sharedDefaultParamsPs, appSpecificParamsPs, + sharedParamsPs)); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + public void testFindOneWithOverrides() { + // Arrange + String application = configServerProperties.getDefaultApplicationName(); + String profile = configServerProperties.getDefaultProfile(); + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + Map overrides = new HashMap(4) { + { + put("logging.level.root", "boom"); + put("logging.level.com.example.service", "boom"); + put("spring.cache.redis.time-to-live", "-1"); + } + }; + + configServerProperties.setOverrides(overrides); + + PropertySource overridesPs = new PropertySource("overrides", overrides); + + String sharedDefaultParamsPsName = "aws:ssm:parameter:/config/application-default/"; + PropertySource sharedDefaultParamsPs = new PropertySource( + sharedDefaultParamsPsName, SHARED_DEFAULT_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(application, profiles, null, null, null); + + expected.addAll( + Arrays.asList(overridesPs, sharedDefaultParamsPs, sharedParamsPs)); + + setupAwsSsmClientMocks(expected); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + public void testFindOneWithSlashesInTheParameterKeyPath() { + // Arrange + String application = configServerProperties.getDefaultApplicationName(); + String profile = configServerProperties.getDefaultProfile(); + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + String sharedDefaultParamsPsName = "aws:ssm:parameter:/config/application-default/"; + PropertySource sharedDefaultParamsPs = new PropertySource( + sharedDefaultParamsPsName, SHARED_DEFAULT_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(application, profiles, null, null, null); + expected.addAll(Arrays.asList(sharedDefaultParamsPs, sharedParamsPs)); + + setupAwsSsmClientMocks(expected, true, false); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + public void testFindOneWithPaginatedAwsSsmClientResponse() { + // Arrange + String application = configServerProperties.getDefaultApplicationName(); + String profile = configServerProperties.getDefaultProfile(); + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + environmentProperties.setMaxResults(1); + + String sharedDefaultParamsPsName = "aws:ssm:parameter:/config/application-default/"; + PropertySource sharedDefaultParamsPs = new PropertySource( + sharedDefaultParamsPsName, SHARED_DEFAULT_PROPERTIES); + + String sharedParamsPsName = "aws:ssm:parameter:/config/application/"; + PropertySource sharedParamsPs = new PropertySource(sharedParamsPsName, + SHARED_PROPERTIES); + + Environment expected = new Environment(application, profiles, null, null, null); + expected.addAll(Arrays.asList(sharedDefaultParamsPs, sharedParamsPs)); + + setupAwsSsmClientMocks(expected, false, true); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + @Test + public void testFindOneWithNoParametersInThePaths() { + // Arrange + String application = configServerProperties.getDefaultApplicationName(); + String profile = configServerProperties.getDefaultProfile(); + String[] profiles = StringUtils.commaDelimitedListToStringArray(profile); + + Environment expected = new Environment(application, profiles, null, null, null); + + when(awsSsmClientMock.getParametersByPath(any(GetParametersByPathRequest.class))) + .thenReturn(new GetParametersByPathResult()); + + // Act + Environment result = repository.findOne(application, profile, null); + + // Assert + assertThat(result).usingRecursiveComparison().withStrictTypeChecking() + .isEqualTo(expected); + } + + private void setupAwsSsmClientMocks(Environment environment) { + setupAwsSsmClientMocks(environment, false, false); + } + + private void setupAwsSsmClientMocks(Environment environment, + boolean withSlashesForPropertyName, boolean paginatedResponse) { + for (PropertySource ps : environment.getPropertySources()) { + String path = StringUtils.delete(ps.getName(), + environmentProperties.getOrigin()); + + GetParametersByPathRequest request = new GetParametersByPathRequest() + .withPath(path).withRecursive(environmentProperties.isRecursive()) + .withWithDecryption(environmentProperties.isDecryptValues()) + .withMaxResults(environmentProperties.getMaxResults()); + + Set parameters = getParameters(ps, path, + withSlashesForPropertyName); + + GetParametersByPathResult response = new GetParametersByPathResult() + .withParameters(parameters); + + if (paginatedResponse + && environmentProperties.getMaxResults() < parameters.size()) { + List> chunks = splitParametersIntoChunks(parameters); + + String nextToken = null; + + for (int i = 0; i < chunks.size(); i++) { + Set chunk = chunks.get(i); + + if (i == 0) { + nextToken = generateNextToken(); + + GetParametersByPathResult responseClone = response.clone() + .withParameters(chunk).withNextToken(nextToken); + + when(awsSsmClientMock.getParametersByPath(eq(request))) + .thenReturn(responseClone); + } + else if (i == chunks.size() - 1) { + GetParametersByPathRequest requestClone = request.clone().withNextToken(nextToken); + GetParametersByPathResult responseClone = response.clone().withParameters(chunk); + + when(awsSsmClientMock.getParametersByPath(eq(requestClone))) + .thenReturn(responseClone); + } + else { + String newNextToken = generateNextToken(); + + GetParametersByPathRequest requestClone = request.clone() + .withNextToken(nextToken); + + GetParametersByPathResult responseClone = response.clone() + .withParameters(chunk).withNextToken(newNextToken); + + when(awsSsmClientMock.getParametersByPath(eq(requestClone))) + .thenReturn(responseClone); + + nextToken = newNextToken; + } + } + } + else { + when(awsSsmClientMock.getParametersByPath(eq(request))) + .thenReturn(response); + } + } + } + + private Set getParameters(PropertySource propertySource, String path, + boolean withSlashesForPropertyName) { + Function, Parameter> mapper = p -> new Parameter() + .withName(path + (withSlashesForPropertyName + ? ((String) p.getKey()).replace(".", DEFAULT_PATH_SEPARATOR) + : p.getKey())) + .withType(ParameterType.String).withValue((String) p.getValue()) + .withVersion(1L); + + return propertySource.getSource().entrySet().stream().map(mapper) + .collect(Collectors.toSet()); + } + + private List> splitParametersIntoChunks(Set parameters) { + AtomicInteger counter = new AtomicInteger(); + + Collector>> collector = Collectors + .groupingBy( + p -> counter.getAndIncrement() + / environmentProperties.getMaxResults(), + Collectors.toSet()); + + return new ArrayList<>(parameters.stream().collect(collector).values()); + } + + private String generateNextToken() { + String random = randomAlphabetic(RandomUtils.nextInt(3, 33)); + + return Base64.getEncoder() + .encodeToString(random.getBytes(StandardCharsets.UTF_8)); + } + +}