diff --git a/docs/src/main/asciidoc/appconfig.adoc b/docs/src/main/asciidoc/appconfig.adoc new file mode 100644 index 0000000000..6381c931a9 --- /dev/null +++ b/docs/src/main/asciidoc/appconfig.adoc @@ -0,0 +1,185 @@ +[#spring-cloud-aws-app-config] +== AppConfig Integration + +https://docs.aws.amazon.com/appconfig/latest/userguide/what-is-appconfig.html[AppConfig] helps store configuration safely in the cloud. The service also supports continuous fetching of changes so they can be applied during runtime. + +Spring Cloud AWS adds support for loading configuration from AppConfig through the Spring Boot https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-external-config-files-importing[config import feature]. + +Maven coordinates, using <>: + +[source,xml] +---- + + io.awspring.cloud + spring-cloud-aws-starter-app-config + +---- + +=== Loading External Configuration + +To fetch configuration from AppConfig and add it to Spring's environment properties, add the `spring.config.import` property to `application.properties`: + +When specifying the key, all of the following values must be set: + +1. ApplicationName or ApplicationIdentifier +2. ConfigurationProfileName or ConfigurationProfileIdentifier +3. EnvironmentName or EnvironmentIdentifier + +Spring Cloud AWS AppConfig supports the following file types: + +1. YAML +2. Plain text (properties) +3. JSON + +For example, assuming that the ApplicationName in AppConfig is `cool-spring-application`, ConfigurationProfileName is `ProductionConfig`, and EnvironmentName is `prod`: + +[source,properties] +---- +spring.config.import=aws-app-config:cool-spring-application#ProductionConfig#prod +---- + +If a config with the given values does not exist in AppConfig, the application will fail to start. If the config file is not required for the application and it should continue to start even when the file is missing, add `optional` before the prefix: + +[source,properties] +---- +spring.config.import=optional:aws-app-config:cool-spring-application#ProductionConfig#prod +---- + +To load multiple configurations, separate their names with `;`: + +[source,properties] +---- +spring.config.import=aws-app-config:cool-spring-application#ProductionConfig#prod;new-spring-application#ProdConfig#prod2 +---- + +If some config files are required and others are optional, list them as separate entries in the `spring.config.import` property: + +[source,properties] +---- +spring.config.import[0]=optional:aws-app-config:new-spring-application#ProdConfig#prod +spring.config.import[1]=aws-app-config:cool-spring-application#ProductionConfig#prod2 +---- + +Fetched config file properties can be referenced with `@Value`, bound to `@ConfigurationProperties` classes, or referenced in the `application.properties` file. + +[source,java] +---- +@Value("${username}") +private String username; + +@Value("${password}") +private String password; +---- + +=== Using AppConfigDataClient + +The starter automatically configures and registers a `AppConfigDataClient` bean in the Spring application context. The `AppConfigDataClient` bean can be used to retrieve configuration files imperatively. + +=== Customizing AppConfigDataClient + +To use a custom `AppConfigDataClient` in `spring.config.import`, provide an implementation of `BootstrapRegistryInitializer`. For example: + +[source,java] +---- +package com.app; + + +import org.springframework.boot.BootstrapRegistry; +import org.springframework.boot.BootstrapRegistryInitializer; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; + +public class AppConfigBootstrapConfiguration implements BootstrapRegistryInitializer { + + @Override + public void initialize(BootstrapRegistry registry) { + registry.register(AppConfigDataClient.class, context -> { + AwsCredentialsProvider awsCredentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create("yourAccessKey", "yourSecretKey")); + return AppConfigDataClient.builder().credentialsProvider(awsCredentialsProvider).region(Region.EU_WEST_2).build(); + }); + } +} +---- + +Note that this class must be listed under the `org.springframework.boot.BootstrapRegistryInitializer` key in `META-INF/spring.factories`: + +[source,properties] +---- +org.springframework.boot.BootstrapRegistryInitializer=com.app.AppConfigBootstrapConfiguration +---- + + +If you want to use autoconfigured `AppConfigDataClient` but change underlying SDKClient or `ClientOverrideConfiguration` you will need to register bean of type `AppConfigClientCustomizer`: +Autoconfiguration will configure `AppConfigDataClient` Bean with provided values after that, for example: + +[source,java] +---- +package com.app; + +import io.awspring.cloud.autoconfigure.config.appconfig.AppConfigClientCustomizer; +import java.time.Duration; +import org.springframework.boot.BootstrapRegistry; +import org.springframework.boot.BootstrapRegistryInitializer; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.apache.ApacheHttpClient; +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClientBuilder; + +class AppConfigBootstrapConfiguration implements BootstrapRegistryInitializer { + + @Override + public void initialize(BootstrapRegistry registry) { + registry.register(AppConfigClientCustomizer.class, context -> (builder -> { + builder.overrideConfiguration(builder.overrideConfiguration().copy(c -> { + c.apiCallTimeout(Duration.ofMillis(2001)); + })); + })); + } +} +---- + +=== Configuration + +The Spring Boot Starter for AppConfig provides the following configuration options: + +[cols="2,3,1,1"] +|=== +| Name | Description | Required | Default value +| `spring.cloud.aws.appconfig.enabled` | Enables the AppConfig integration. | No | `true` +| `spring.cloud.aws.appconfig.endpoint` | Configures the endpoint used by `AppConfigDataClient`. | No | `null` +| `spring.cloud.aws.appconfig.region` | Configures the region used by `AppConfigDataClient`. | No | `null` +| `spring.cloud.aws.appconfig.separator` | Configures the separator used to split the import key into application, profile, and environment. | No | `#` +|=== + +=== IAM Permissions + +The following IAM permissions are required by Spring Cloud AWS: + +[cols="2"] +|=== +| Start session | `appconfig:StartConfigurationSession` +| Get configuration | `appconfig:GetLatestConfiguration` +|=== + +Sample IAM policy granting access to AppConfig: + +[source,json,indent=0] +---- +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "appconfig:GetLatestConfiguration", + "appconfig:StartConfigurationSession" + ], + "Resource": "yourARN" + } + ] +} +---- diff --git a/docs/src/main/asciidoc/index.adoc b/docs/src/main/asciidoc/index.adoc index 5adaf4055f..287ce51c22 100644 --- a/docs/src/main/asciidoc/index.adoc +++ b/docs/src/main/asciidoc/index.adoc @@ -162,6 +162,8 @@ include::spring-modulith.adoc[] include::kinesis-stream-binder.adoc[] +include::appconfig.adoc[] + include::testing.adoc[] include::docker-compose.adoc[] diff --git a/pom.xml b/pom.xml index 73ea61f98b..d9a7e33c3d 100644 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,7 @@ spring-cloud-aws-sns spring-cloud-aws-sqs spring-cloud-aws-dynamodb + spring-cloud-aws-app-config spring-cloud-aws-kinesis spring-cloud-aws-kinesis-stream-binder spring-cloud-aws-starters/spring-cloud-aws-starter-integration-kinesis @@ -57,6 +58,7 @@ spring-cloud-aws-starters/spring-cloud-aws-starter-metrics spring-cloud-aws-starters/spring-cloud-aws-starter-parameter-store spring-cloud-aws-starters/spring-cloud-aws-starter-s3 + spring-cloud-aws-starters/spring-cloud-aws-starter-app-config spring-cloud-aws-starters/spring-cloud-aws-starter-integration-s3 spring-cloud-aws-starters/spring-cloud-aws-starter-secrets-manager spring-cloud-aws-starters/spring-cloud-aws-starter-ses diff --git a/spring-cloud-aws-app-config/pom.xml b/spring-cloud-aws-app-config/pom.xml new file mode 100644 index 0000000000..e3d9388c79 --- /dev/null +++ b/spring-cloud-aws-app-config/pom.xml @@ -0,0 +1,37 @@ + + + + io.awspring.cloud + spring-cloud-aws + 4.0.1-SNAPSHOT + + 4.0.0 + + spring-cloud-aws-app-config + Spring Cloud AWS App Config Integration + + + + org.springframework + spring-core + + + io.awspring.cloud + spring-cloud-aws-core + + + org.springframework + spring-context + + + software.amazon.awssdk + appconfigdata + + + tools.jackson.core + jackson-databind + + + diff --git a/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/AppConfigPropertySource.java b/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/AppConfigPropertySource.java new file mode 100644 index 0000000000..c21e7a4663 --- /dev/null +++ b/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/AppConfigPropertySource.java @@ -0,0 +1,127 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.appconfig; + +import io.awspring.cloud.core.config.AwsPropertySource; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; +import org.jspecify.annotations.Nullable; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.io.InputStreamResource; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; +import software.amazon.awssdk.services.appconfigdata.model.GetLatestConfigurationRequest; +import software.amazon.awssdk.services.appconfigdata.model.GetLatestConfigurationResponse; +import software.amazon.awssdk.services.appconfigdata.model.StartConfigurationSessionRequest; + +/** + * Retrieves configuration property sources path from the AWS AppConfig using the provided {@link AppConfigDataClient}. + * + * @author Matej Nedic + * @since 4.1.0 + */ +public class AppConfigPropertySource extends AwsPropertySource { + private static final String YAML_TYPE = "application/x-yaml"; + private static final String YAML_TYPE_ALTERNATIVE = "text/yaml"; + private static final String TEXT_TYPE = "text/plain"; + private static final String JSON_TYPE = "application/json"; + + private final RequestContext context; + private final AppConfigDataClient appConfigClient; + private String sessionToken; + private final Map properties; + + public AppConfigPropertySource(RequestContext context, AppConfigDataClient appConfigClient) { + this(context, appConfigClient, null, null); + } + + public AppConfigPropertySource(RequestContext context, AppConfigDataClient appConfigClient, + @Nullable String sessionToken, Map properties) { + super("aws-appconfig:" + context, appConfigClient); + Assert.notNull(context, "context is required"); + this.context = context; + this.appConfigClient = appConfigClient; + this.sessionToken = sessionToken; + this.properties = properties != null ? properties : new LinkedHashMap<>(); + } + + @Override + public void init() { + if (!StringUtils.hasText(sessionToken)) { + var request = StartConfigurationSessionRequest.builder() + .applicationIdentifier(context.getApplicationIdentifier()) + .environmentIdentifier(context.getEnvironmentIdentifier()) + .configurationProfileIdentifier(context.getConfigurationProfileIdentifier()).build(); + sessionToken = appConfigClient.startConfigurationSession(request).initialConfigurationToken(); + } + + GetLatestConfigurationRequest request = GetLatestConfigurationRequest.builder().configurationToken(sessionToken) + .build(); + GetLatestConfigurationResponse response = this.source.getLatestConfiguration(request); + if (response.configuration().asByteArray().length > 0) { + properties.clear(); + var props = switch (response.contentType()) { + case TEXT_TYPE -> readProperties(response.configuration().asInputStream()); + case YAML_TYPE, YAML_TYPE_ALTERNATIVE, JSON_TYPE -> readYaml(response.configuration().asInputStream()); + default -> throw new IllegalStateException("Cannot parse unknown content type: " + response.contentType()); + }; + for (Map.Entry entry : props.entrySet()) { + properties.put(String.valueOf(entry.getKey()), entry.getValue()); + } + } + sessionToken = response.nextPollConfigurationToken(); + } + + @Override + public AppConfigPropertySource copy() { + return new AppConfigPropertySource(context, appConfigClient, sessionToken, new LinkedHashMap<>(properties)); + } + + @Override + public String[] getPropertyNames() { + return properties.keySet().toArray(String[]::new); + } + + @Override + public @Nullable Object getProperty(String name) { + return this.properties.get(name); + } + + private Properties readProperties(InputStream inputStream) { + Properties properties = new Properties(); + try (InputStream in = inputStream) { + properties.load(in); + } + catch (Exception e) { + throw new IllegalStateException("Cannot load environment", e); + } + return properties; + } + + private Properties readYaml(InputStream inputStream) { + YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); + try (InputStream in = inputStream) { + yaml.setResources(new InputStreamResource(in)); + return yaml.getObject(); + } + catch (Exception e) { + throw new IllegalStateException("Cannot load environment", e); + } + } +} diff --git a/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/RequestContext.java b/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/RequestContext.java new file mode 100644 index 0000000000..7d7215fdac --- /dev/null +++ b/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/RequestContext.java @@ -0,0 +1,102 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.appconfig; + +import java.util.Objects; + +/** + * Context used by {@link AppConfigPropertySource} to construct a call to AppConfig. + * + * @author Matej Nedic + * @since 4.1.0 + */ +public class RequestContext { + /** + * ConfigurationProfileIdentifier or ConfigurationProfileName that is used to fetch SessionToken. + */ + private String configurationProfileIdentifier; + /** + * EnvironmentIdentifier or EnvironmentName that is used to fetch SessionToken. + */ + private String environmentIdentifier; + /** + * ApplicationIdentifier or ApplicationName that is used to fetch SessionToken. + */ + private String applicationIdentifier; + /** + * Full context name which was used to extract values configurationProfileIdentifier, environmentIdentifier and + * applicationIdentifier. + */ + private String context; + + public RequestContext(String configurationProfileIdentifier, String environmentIdentifier, + String applicationIdentifier, String context) { + this.configurationProfileIdentifier = configurationProfileIdentifier; + this.environmentIdentifier = environmentIdentifier; + this.applicationIdentifier = applicationIdentifier; + this.context = context; + } + + public String getConfigurationProfileIdentifier() { + return configurationProfileIdentifier; + } + + public void setConfigurationProfileIdentifier(String configurationProfileIdentifier) { + this.configurationProfileIdentifier = configurationProfileIdentifier; + } + + public String getEnvironmentIdentifier() { + return environmentIdentifier; + } + + public void setEnvironmentIdentifier(String environmentIdentifier) { + this.environmentIdentifier = environmentIdentifier; + } + + public String getApplicationIdentifier() { + return applicationIdentifier; + } + + public void setApplicationIdentifier(String applicationIdentifier) { + this.applicationIdentifier = applicationIdentifier; + } + + public String getContext() { + return context; + } + + public void setContext(String context) { + this.context = context; + } + + @Override + public boolean equals(Object object) { + if (this == object) + return true; + if (object == null || getClass() != object.getClass()) + return false; + RequestContext that = (RequestContext) object; + return Objects.equals(configurationProfileIdentifier, that.configurationProfileIdentifier) + && Objects.equals(environmentIdentifier, that.environmentIdentifier) + && Objects.equals(applicationIdentifier, that.applicationIdentifier) + && Objects.equals(context, that.context); + } + + @Override + public int hashCode() { + return Objects.hash(configurationProfileIdentifier, environmentIdentifier, applicationIdentifier, context); + } +} diff --git a/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java b/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java new file mode 100644 index 0000000000..1a6159699a --- /dev/null +++ b/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java @@ -0,0 +1,302 @@ +/* + * Copyright 2013-2022 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 io.awspring.cloud.appconfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +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.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; +import software.amazon.awssdk.services.appconfigdata.model.GetLatestConfigurationRequest; +import software.amazon.awssdk.services.appconfigdata.model.GetLatestConfigurationResponse; +import software.amazon.awssdk.services.appconfigdata.model.StartConfigurationSessionRequest; +import software.amazon.awssdk.services.appconfigdata.model.StartConfigurationSessionResponse; + +/** + * Tests for {@link AppConfigPropertySource}. + * + * @author Matej Nedic + */ +class AppConfigPropertySourceTest { + + private static final RequestContext DEFAULT_CONTEXT = new RequestContext("profile1", "env1", "app1", + "app1#profile1#env1"); + + private static final String RESOURCE_PATH = "io/awspring/cloud/appconfig/"; + + private final AppConfigDataClient client = mock(AppConfigDataClient.class); + + private static String loadResource(String filename) { + try { + ClassPathResource resource = new ClassPathResource(RESOURCE_PATH + filename); + return new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + } + catch (IOException e) { + throw new RuntimeException("Failed to load test resource: " + filename, e); + } + } + + @BeforeEach + void setupDefaultSessionMock() { + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) + .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); + } + + @Test + void shouldParsePropertiesContentType() { + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); + + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))).thenAnswer(invocation -> { + StartConfigurationSessionRequest request = invocation.getArgument(0); + assertThat(request.applicationIdentifier()).isEqualTo("app1"); + assertThat(request.environmentIdentifier()).isEqualTo("env1"); + assertThat(request.configurationProfileIdentifier()).isEqualTo("profile1"); + return StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build(); + }); + + GetLatestConfigurationResponse response = buildResponse(loadResource("test-config.properties"), "text/plain"); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenAnswer(invocation -> { + GetLatestConfigurationRequest request = invocation.getArgument(0); + assertThat(request.configurationToken()).isEqualTo("initial-token"); + return response; + }); + + propertySource.init(); + + assertThat(propertySource.getName()).isEqualTo("aws-appconfig:" + DEFAULT_CONTEXT); + assertThat(propertySource.getPropertyNames()).containsExactlyInAnyOrder("key1", "key2"); + assertThat(propertySource.getProperty("key1")).isEqualTo("value1"); + assertThat(propertySource.getProperty("key2")).isEqualTo("value2"); + } + + @Test + void shouldParseYamlContentType() { + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); + + GetLatestConfigurationResponse response = buildResponse(loadResource("test-config.yaml"), "application/x-yaml"); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + propertySource.init(); + + assertThat(propertySource.getName()).isEqualTo("aws-appconfig:" + DEFAULT_CONTEXT); + assertThat(propertySource.getPropertyNames()).containsExactlyInAnyOrder("key1", "key2", "nested.key3"); + assertThat(propertySource.getProperty("key1")).isEqualTo("value1"); + assertThat(propertySource.getProperty("key2")).isEqualTo("value2"); + assertThat(propertySource.getProperty("nested.key3")).isEqualTo("value3"); + } + + @Test + void shouldParseAlternativeYamlContentType() { + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); + + GetLatestConfigurationResponse response = buildResponse(loadResource("test-config.yaml"), "text/yaml"); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + propertySource.init(); + + assertThat(propertySource.getPropertyNames()).containsExactlyInAnyOrder("key1", "key2", "nested.key3"); + assertThat(propertySource.getProperty("key1")).isEqualTo("value1"); + } + + @Test + void shouldParseJsonContentType() { + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); + + GetLatestConfigurationResponse response = buildResponse(loadResource("test-config.json"), "application/json"); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + propertySource.init(); + + assertThat(propertySource.getPropertyNames()).containsExactlyInAnyOrder("key1", "key2"); + assertThat(propertySource.getProperty("key1")).isEqualTo("value1"); + assertThat(propertySource.getProperty("key2")).isEqualTo("value2"); + } + + @Test + void throwsExceptionForUnsupportedContentType() { + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); + + GetLatestConfigurationResponse response = buildResponse("some content", "application/xml"); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + assertThatThrownBy(propertySource::init).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot parse unknown content type: application/xml"); + } + + @Test + void shouldInitializeSessionTokenOnFirstInit() { + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); + + GetLatestConfigurationResponse response = buildResponse("key1=value1", "text/plain"); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + propertySource.init(); + + verify(client, times(1)).startConfigurationSession(any(StartConfigurationSessionRequest.class)); + verify(client, times(1)).getLatestConfiguration(any(GetLatestConfigurationRequest.class)); + } + + @Test + void shouldReuseSessionTokenOnSubsequentInit() { + GetLatestConfigurationResponse response = buildResponse("key1=value1", "text/plain"); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenAnswer(invocation -> { + GetLatestConfigurationRequest request = invocation.getArgument(0); + assertThat(request.configurationToken()).isEqualTo("existing-token"); + return response; + }); + + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client, "existing-token", + null); + propertySource.init(); + + // Should NOT call startConfigurationSession since token already exists + verify(client, times(0)).startConfigurationSession(any(StartConfigurationSessionRequest.class)); + verify(client, times(1)).getLatestConfiguration(any(GetLatestConfigurationRequest.class)); + } + + @Test + void shouldHandleEmptyConfigurationResponse() { + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); + + GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromByteArray(new byte[0])).contentType("text/plain") + .nextPollConfigurationToken("next-token").build(); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenAnswer(invocation -> { + GetLatestConfigurationRequest request = invocation.getArgument(0); + assertThat(request.configurationToken()).isEqualTo("initial-token"); + return response; + }); + + propertySource.init(); + + assertThat(propertySource.getPropertyNames()).isEmpty(); + } + + @Test + void shouldUpdateNextPollTokenAfterInit() { + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); + + GetLatestConfigurationResponse firstResponse = buildResponse("key1=value1", "text/plain", "next-token-1"); + GetLatestConfigurationResponse secondResponse = buildResponse("key2=value2", "text/plain", "next-token-2"); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenAnswer(invocation -> { + GetLatestConfigurationRequest request = invocation.getArgument(0); + assertThat(request.configurationToken()).isEqualTo("initial-token"); + return firstResponse; + }).thenAnswer(invocation -> { + GetLatestConfigurationRequest request = invocation.getArgument(0); + assertThat(request.configurationToken()).isEqualTo("next-token-1"); + return secondResponse; + }); + + propertySource.init(); + assertThat(propertySource.getProperty("key1")).isEqualTo("value1"); + + // Second init should use the next poll token from first response + propertySource.init(); + assertThat(propertySource.getProperty("key2")).isEqualTo("value2"); + } + + @Test + void copyShouldPreserveSessionTokenAndProperties() { + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); + + GetLatestConfigurationResponse response = buildResponse(loadResource("test-config.properties"), "text/plain"); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + propertySource.init(); + + AppConfigPropertySource copy = propertySource.copy(); + + assertThat(copy.getName()).isEqualTo(propertySource.getName()); + assertThat(copy.getPropertyNames()).containsExactlyInAnyOrder(propertySource.getPropertyNames()); + assertThat(copy.getProperty("key1")).isEqualTo("value1"); + assertThat(copy.getProperty("key2")).isEqualTo("value2"); + } + + @Test + void copyShouldNotCallStartSessionAgain() { + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); + + GetLatestConfigurationResponse response = buildResponse("key1=value1", "text/plain"); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + propertySource.init(); + + AppConfigPropertySource copy = propertySource.copy(); + + GetLatestConfigurationResponse secondResponse = buildResponse("key2=value2", "text/plain", "next-token-2"); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(secondResponse); + + copy.init(); + + verify(client, times(1)).startConfigurationSession(any(StartConfigurationSessionRequest.class)); + } + + @Test + void shouldUseCorrectContextIdentifiers() { + RequestContext context = new RequestContext("myProfile", "myEnv", "myApp", "myApp#myProfile#myEnv"); + AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))).thenAnswer(invocation -> { + StartConfigurationSessionRequest request = invocation.getArgument(0); + assertThat(request.applicationIdentifier()).isEqualTo("myApp"); + assertThat(request.environmentIdentifier()).isEqualTo("myEnv"); + assertThat(request.configurationProfileIdentifier()).isEqualTo("myProfile"); + return StartConfigurationSessionResponse.builder().initialConfigurationToken("token").build(); + }); + + GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromByteArray(new byte[0])).contentType("text/plain") + .nextPollConfigurationToken("next-token").build(); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + propertySource.init(); + + verify(client, times(1)).startConfigurationSession(any(StartConfigurationSessionRequest.class)); + } + + private GetLatestConfigurationResponse buildResponse(String content, String contentType) { + return buildResponse(content, contentType, "next-token"); + } + + private GetLatestConfigurationResponse buildResponse(String content, String contentType, String nextToken) { + return GetLatestConfigurationResponse.builder().configuration(SdkBytes.fromUtf8String(content)) + .contentType(contentType).nextPollConfigurationToken(nextToken).build(); + } +} diff --git a/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.json b/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.json new file mode 100644 index 0000000000..e78fb996b1 --- /dev/null +++ b/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.json @@ -0,0 +1 @@ +{"key1": "value1", "key2": "value2"} diff --git a/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.properties b/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.properties new file mode 100644 index 0000000000..48580bfcce --- /dev/null +++ b/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.properties @@ -0,0 +1,2 @@ +key1=value1 +key2=value2 diff --git a/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.yaml b/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.yaml new file mode 100644 index 0000000000..1527ce0040 --- /dev/null +++ b/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.yaml @@ -0,0 +1,4 @@ +key1: value1 +key2: value2 +nested: + key3: value3 diff --git a/spring-cloud-aws-autoconfigure/pom.xml b/spring-cloud-aws-autoconfigure/pom.xml index a52e422283..b682136022 100644 --- a/spring-cloud-aws-autoconfigure/pom.xml +++ b/spring-cloud-aws-autoconfigure/pom.xml @@ -71,6 +71,11 @@ spring-cloud-aws-secrets-manager true + + io.awspring.cloud + spring-cloud-aws-app-config + true + io.awspring.cloud spring-cloud-aws-ses @@ -207,6 +212,11 @@ s3vectors true + + software.amazon.awssdk + appconfig + test + io.micrometer micrometer-observation-test diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigClientCustomizer.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigClientCustomizer.java new file mode 100644 index 0000000000..abdab8c274 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigClientCustomizer.java @@ -0,0 +1,27 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.autoconfigure.config.appconfig; + +import io.awspring.cloud.autoconfigure.AwsClientCustomizer; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClientBuilder; + +/** + * Callback interface that can be used to customize a {@link AppConfigDataClientBuilder}. + * + * @author Matej Nedic + */ +public interface AppConfigClientCustomizer extends AwsClientCustomizer { +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoader.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoader.java new file mode 100644 index 0000000000..002b80f61b --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoader.java @@ -0,0 +1,65 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.autoconfigure.config.appconfig; + +import io.awspring.cloud.appconfig.AppConfigPropertySource; +import io.awspring.cloud.autoconfigure.config.BootstrapLoggingHelper; +import java.util.Collections; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.springframework.boot.context.config.ConfigData; +import org.springframework.boot.context.config.ConfigDataLoader; +import org.springframework.boot.context.config.ConfigDataLoaderContext; +import org.springframework.boot.logging.DeferredLogFactory; +import org.springframework.core.env.MapPropertySource; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; + +/** + * Loads config data from AWS AppConfig. + * + * @author Matej Nedic + * @since 4.1.0 + */ +public class AppConfigConfigDataLoader implements ConfigDataLoader { + + public AppConfigConfigDataLoader(DeferredLogFactory logFactory) { + BootstrapLoggingHelper.reconfigureLoggers(logFactory, "io.awspring.cloud.appconfig.AppConfigPropertySource", + "io.awspring.cloud.autoconfigure.config.appconfig.AppConfigPropertySources"); + } + + @Override + @Nullable + public ConfigData load(ConfigDataLoaderContext context, AppConfigDataResource resource) { + // resource is disabled if appconfig integration is disabled via + // spring.cloud.aws.appconfig.enabled=false + if (resource.isEnabled()) { + AppConfigDataClient appConfigDataClient = context.getBootstrapContext().get(AppConfigDataClient.class); + AppConfigPropertySource propertySource = resource.getPropertySources() + .createPropertySource(resource.getContext(), resource.isOptional(), appConfigDataClient); + if (propertySource != null) { + return new ConfigData(Collections.singletonList(propertySource)); + } + else { + return null; + } + } + else { + // create dummy empty config data + return new ConfigData( + Collections.singletonList(new MapPropertySource("aws-appconfig:" + context, Map.of()))); + } + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java new file mode 100644 index 0000000000..fbe52ca13e --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java @@ -0,0 +1,137 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.autoconfigure.config.appconfig; + +import io.awspring.cloud.appconfig.RequestContext; +import io.awspring.cloud.autoconfigure.AwsSyncClientCustomizer; +import io.awspring.cloud.autoconfigure.config.AbstractAwsConfigDataLocationResolver; +import io.awspring.cloud.autoconfigure.core.AwsProperties; +import io.awspring.cloud.autoconfigure.core.CredentialsProperties; +import io.awspring.cloud.autoconfigure.core.RegionProperties; +import java.util.ArrayList; +import java.util.List; +import org.apache.commons.logging.Log; +import org.springframework.boot.bootstrap.BootstrapContext; +import org.springframework.boot.context.config.ConfigDataLocation; +import org.springframework.boot.context.config.ConfigDataLocationNotFoundException; +import org.springframework.boot.context.config.ConfigDataLocationResolverContext; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.logging.DeferredLogFactory; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClientBuilder; + +/** + * Resolves config data locations in AWS App Config. + * @author Matej Nedic + * @since 4.1.0 + */ +public class AppConfigDataLocationResolver extends AbstractAwsConfigDataLocationResolver { + + /** + * AWS App Config Data prefix. + */ + public static final String PREFIX = "aws-appconfig:"; + + private final Log log; + + public AppConfigDataLocationResolver(DeferredLogFactory deferredLogFactory) { + this.log = deferredLogFactory.getLog(AppConfigDataLocationResolver.class); + } + + @Override + protected String getPrefix() { + return PREFIX; + } + + @Override + public List resolve(ConfigDataLocationResolverContext resolverContext, + ConfigDataLocation location) throws ConfigDataLocationNotFoundException { + AppConfigProperties appConfigProperties = loadProperties(resolverContext.getBinder()); + List locations = new ArrayList<>(); + AppConfigPropertySources propertySources = new AppConfigPropertySources(); + List contexts = getCustomContexts(location.getNonPrefixedValue(PREFIX)); + + if (appConfigProperties.isEnabled()) { + registerBean(resolverContext, AwsProperties.class, loadAwsProperties(resolverContext.getBinder())); + registerBean(resolverContext, AppConfigProperties.class, appConfigProperties); + registerBean(resolverContext, CredentialsProperties.class, + loadCredentialsProperties(resolverContext.getBinder())); + registerBean(resolverContext, RegionProperties.class, loadRegionProperties(resolverContext.getBinder())); + + registerAndPromoteBean(resolverContext, AppConfigDataClient.class, this::createAppConfigDataClient); + + contexts.forEach(propertySourceContext -> locations.add( + new AppConfigDataResource(resolveContext(propertySourceContext, appConfigProperties.getSeparator()), + location.isOptional(), propertySources))); + + if (!location.isOptional() && locations.isEmpty()) { + throw new AppConfigKeysMissingException( + "No AppConfigData keys provided in `spring.config.import=aws-appconfig:` configuration."); + } + } + else { + // create dummy resources with enabled flag set to false, + // because returned locations cannot be empty + contexts.forEach(propertySourceContext -> locations.add( + new AppConfigDataResource(resolveContext(propertySourceContext, appConfigProperties.getSeparator()), + location.isOptional(), false, propertySources))); + } + return locations; + } + + private RequestContext resolveContext(String propertySourceContext, String separator) { + var response = propertySourceContext.split(java.util.regex.Pattern.quote(separator)); + // Format: ApplicationName#ConfigurationProfileName#EnvironmentName + String applicationIdentifier = response[0].trim(); + String configurationProfileIdentifier = response[1].trim(); + String environmentIdentifier = response[2].trim(); + return new RequestContext(configurationProfileIdentifier, environmentIdentifier, applicationIdentifier, + propertySourceContext); + } + + private AppConfigDataClient createAppConfigDataClient(BootstrapContext context) { + AppConfigDataClientBuilder builder = configure(AppConfigDataClient.builder(), + context.get(AppConfigProperties.class), context); + + try { + AppConfigClientCustomizer appConfigClientCustomizer = context.get(AppConfigClientCustomizer.class); + if (appConfigClientCustomizer != null) { + appConfigClientCustomizer.customize(builder); + } + } + catch (IllegalStateException e) { + log.debug("Bean of type AwsSyncClientCustomizer is not registered: " + e.getMessage()); + } + + try { + AwsSyncClientCustomizer awsSyncClientCustomizer = context.get(AwsSyncClientCustomizer.class); + if (awsSyncClientCustomizer != null) { + awsSyncClientCustomizer.customize(builder); + } + } + catch (IllegalStateException e) { + log.debug("Bean of type AwsSyncClientCustomizer is not registered: " + e.getMessage()); + } + + return builder.build(); + } + + protected AppConfigProperties loadProperties(Binder binder) { + return binder.bind(AppConfigProperties.PREFIX, Bindable.of(AppConfigProperties.class)) + .orElseGet(AppConfigProperties::new); + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataResource.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataResource.java new file mode 100644 index 0000000000..9c66fbf73d --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataResource.java @@ -0,0 +1,98 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.autoconfigure.config.appconfig; + +import io.awspring.cloud.appconfig.RequestContext; +import java.util.Objects; +import org.springframework.boot.context.config.ConfigDataResource; +import org.springframework.core.style.ToStringCreator; + +/** + * Config data resource for AWS App Config integration. + * @author Matej Nedic + * @since 4.1.0 + */ +public class AppConfigDataResource extends ConfigDataResource { + + private final RequestContext context; + + private final boolean enabled; + + private final boolean optional; + + private final AppConfigPropertySources propertySources; + + public AppConfigDataResource(RequestContext context, boolean optional, AppConfigPropertySources propertySources) { + this(context, optional, true, propertySources); + } + + public AppConfigDataResource(RequestContext context, boolean optional, boolean enabled, + AppConfigPropertySources propertySources) { + this.context = context; + this.optional = optional; + this.propertySources = propertySources; + this.enabled = enabled; + } + + /** + * Returns context which is equal to S3 bucket and property file. + * + * @return the context + */ + public RequestContext getContext() { + return this.context; + } + + /** + * If application startup should fail when secret cannot be loaded or does not exist. + * + * @return is optional + */ + public boolean isOptional() { + return this.optional; + } + + public boolean isEnabled() { + return enabled; + } + + public AppConfigPropertySources getPropertySources() { + return this.propertySources; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AppConfigDataResource that = (AppConfigDataResource) o; + return this.optional == that.optional && this.context.equals(that.context); + } + + @Override + public int hashCode() { + return Objects.hash(this.optional, this.context); + } + + @Override + public String toString() { + return new ToStringCreator(this).append("context", context).append("optional", optional).toString(); + + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigExceptionHappenedAnalyzer.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigExceptionHappenedAnalyzer.java new file mode 100644 index 0000000000..bb56f97692 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigExceptionHappenedAnalyzer.java @@ -0,0 +1,38 @@ +/* + * Copyright 2013-2024 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 io.awspring.cloud.autoconfigure.config.appconfig; + +import org.springframework.boot.diagnostics.AbstractFailureAnalyzer; +import org.springframework.boot.diagnostics.FailureAnalysis; + +/** + * An {@link AbstractFailureAnalyzer} that performs analysis of an AppConfig configuration failure caused by failure of + * {@link software.amazon.awssdk.services.appconfigdata.AppConfigDataClient} call. + * + * @author Matej Nedic + * @since 4.1.0 + */ +public class AppConfigExceptionHappenedAnalyzer + extends AbstractFailureAnalyzer { + + @Override + protected FailureAnalysis analyze(Throwable rootFailure, AwsAppConfigPropertySourceNotFoundException cause) { + return new FailureAnalysis( + "Could not import properties from App Config. Exception happened while trying to load the keys: " + + cause.getMessage(), + "Depending on error message determine action course", cause); + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigKeysMissingException.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigKeysMissingException.java new file mode 100644 index 0000000000..08c4628263 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigKeysMissingException.java @@ -0,0 +1,31 @@ +/* + * Copyright 2013-2023 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 io.awspring.cloud.autoconfigure.config.appconfig; + +/** + * Thrown when configuration provided to ConfigDataLoader is missing AppConfig keys, for example + * `spring.config.import=aws-appconfig:`. + * + * @author Kunal Varpe + * @since 3.3.0 + */ +public class AppConfigKeysMissingException extends RuntimeException { + + public AppConfigKeysMissingException(String message) { + super(message); + } + +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigMissingKeysFailureAnalyzer.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigMissingKeysFailureAnalyzer.java new file mode 100644 index 0000000000..4644632ccc --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigMissingKeysFailureAnalyzer.java @@ -0,0 +1,36 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.autoconfigure.config.appconfig; + +import org.springframework.boot.diagnostics.AbstractFailureAnalyzer; +import org.springframework.boot.diagnostics.FailureAnalysis; + +/** + * An {@link AbstractFailureAnalyzer} that performs analysis of an AppConfig configuration failure caused by not + * providing an AppConfig key to `spring.config.import` property. + * + * @author Matej Nedic + * @since 4.1.0 + */ +public class AppConfigMissingKeysFailureAnalyzer extends AbstractFailureAnalyzer { + + @Override + protected FailureAnalysis analyze(Throwable rootFailure, AppConfigKeysMissingException cause) { + return new FailureAnalysis("Could not import properties from AWS App Config: " + cause.getMessage(), + "Consider providing keys, for example `spring.config.import=aws-appconfig:/config#app#prod`", cause); + } + +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigProperties.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigProperties.java new file mode 100644 index 0000000000..eee1886625 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigProperties.java @@ -0,0 +1,74 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.autoconfigure.config.appconfig; + +import static io.awspring.cloud.autoconfigure.config.appconfig.AppConfigProperties.PREFIX; + +import io.awspring.cloud.autoconfigure.AwsClientProperties; +import io.awspring.cloud.autoconfigure.config.reload.ReloadProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for AWS AppConfig integration. + * @author Matej Nedic + * @since 4.1.0 + */ +@ConfigurationProperties(prefix = PREFIX) +public class AppConfigProperties extends AwsClientProperties { + + /** + * The prefix used for App Config related properties. + */ + public static final String PREFIX = "spring.cloud.aws.appconfig"; + + /** + * Enables App Config import integration. + */ + private boolean enabled = true; + + /** + * Properties related to configuration reload. + */ + @NestedConfigurationProperty + private ReloadProperties reload = new ReloadProperties(); + + private String separator = "#"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getSeparator() { + return separator; + } + + public void setSeparator(String separator) { + this.separator = separator; + } + + public ReloadProperties getReload() { + return reload; + } + + public void setReload(ReloadProperties reload) { + this.reload = reload; + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigPropertySources.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigPropertySources.java new file mode 100644 index 0000000000..a1ca5757d7 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigPropertySources.java @@ -0,0 +1,62 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.autoconfigure.config.appconfig; + +import io.awspring.cloud.appconfig.AppConfigPropertySource; +import io.awspring.cloud.appconfig.RequestContext; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; +import org.springframework.util.Assert; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; + +/** + * Provides prefix config import support. + * + * @author Matej Nedic + * @since 4.1.0 + */ +public class AppConfigPropertySources { + private static final Log LOG = LogFactory.getLog(AppConfigPropertySources.class); + + /** + * Creates property source for given context. + * @param context property source context extracted from the parameter name + * @param optional if creating context should fail with exception if parameter cannot be loaded + * @param client AWS AppConfigData client + * @return a property source or null if config could not be loaded and optional is set to true + */ + @Nullable + public AppConfigPropertySource createPropertySource(RequestContext context, boolean optional, + AppConfigDataClient client) { + Assert.notNull(context, "RequestContext is required"); + Assert.notNull(client, "AppConfigDataClient is required"); + + LOG.info("Loading properties from AWS App Config: " + context + ", optional: " + optional); + try { + AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + propertySource.init(); + return propertySource; + } + catch (Exception e) { + LOG.warn("Unable to load AWS App Config from " + context + ". " + e.getMessage()); + if (!optional) { + throw new AwsAppConfigPropertySourceNotFoundException(e); + } + } + return null; + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfiguration.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfiguration.java new file mode 100644 index 0000000000..34fc8cce56 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfiguration.java @@ -0,0 +1,88 @@ +/* + * Copyright 2013-2022 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 io.awspring.cloud.autoconfigure.config.appconfig; + +import io.awspring.cloud.appconfig.AppConfigPropertySource; +import io.awspring.cloud.autoconfigure.config.reload.ConfigurationChangeDetector; +import io.awspring.cloud.autoconfigure.config.reload.ConfigurationUpdateStrategy; +import io.awspring.cloud.autoconfigure.config.reload.PollingAwsPropertySourceChangeDetector; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.autoconfigure.RefreshAutoConfiguration; +import org.springframework.cloud.autoconfigure.RefreshEndpointAutoConfiguration; +import org.springframework.cloud.commons.util.TaskSchedulerWrapper; +import org.springframework.cloud.context.refresh.ContextRefresher; +import org.springframework.cloud.context.restart.RestartEndpoint; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +/** + * {@link EnableAutoConfiguration Auto-Configuration} for reloading properties from AppConfig. + * + * @author Matej Nedic + * @since 4.1.0 + */ +@AutoConfiguration +@EnableConfigurationProperties(AppConfigProperties.class) +@ConditionalOnClass({ EndpointAutoConfiguration.class, RestartEndpoint.class, ContextRefresher.class }) +@AutoConfigureAfter({ InfoEndpointAutoConfiguration.class, RefreshEndpointAutoConfiguration.class, + RefreshAutoConfiguration.class }) +@ConditionalOnProperty(value = AppConfigProperties.PREFIX + ".reload.strategy") +@ConditionalOnBean(ContextRefresher.class) +public class AppConfigReloadAutoConfiguration { + + @Bean("appConfigTaskScheduler") + @ConditionalOnMissingBean + public TaskSchedulerWrapper appConfigTaskScheduler() { + ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler(); + + threadPoolTaskScheduler.setThreadNamePrefix("spring-cloud-aws-appconfig-ThreadPoolTaskScheduler-"); + threadPoolTaskScheduler.setDaemon(true); + + return new TaskSchedulerWrapper<>(threadPoolTaskScheduler); + } + + @Bean("appConfigConfigurationUpdateStrategy") + @ConditionalOnMissingBean(name = "appConfigConfigurationUpdateStrategy") + public ConfigurationUpdateStrategy appConfigConfigurationUpdateStrategy(AppConfigProperties properties, + Optional restarter, ContextRefresher refresher) { + return ConfigurationUpdateStrategy.create(properties.getReload(), refresher, restarter); + } + + @Bean + @ConditionalOnBean(ConfigurationUpdateStrategy.class) + public ConfigurationChangeDetector appConfigPollingAwsPropertySourceChangeDetector( + AppConfigProperties properties, + @Qualifier("appConfigConfigurationUpdateStrategy") ConfigurationUpdateStrategy strategy, + @Qualifier("appConfigTaskScheduler") TaskSchedulerWrapper taskScheduler, + ConfigurableEnvironment environment) { + + return new PollingAwsPropertySourceChangeDetector<>(properties.getReload(), AppConfigPropertySource.class, + strategy, taskScheduler.getTaskScheduler(), environment); + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AwsAppConfigPropertySourceNotFoundException.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AwsAppConfigPropertySourceNotFoundException.java new file mode 100644 index 0000000000..0825fb40fa --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AwsAppConfigPropertySourceNotFoundException.java @@ -0,0 +1,28 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.autoconfigure.config.appconfig; + +/** + * An exception thrown if there is failure when calling AppConfig. + * + * @author Matej Nedic + * @since 4.1.0 + */ +public class AwsAppConfigPropertySourceNotFoundException extends RuntimeException { + public AwsAppConfigPropertySourceNotFoundException(Exception e) { + super(e); + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 13c7a0de54..fc05b77416 100644 --- a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -29,6 +29,12 @@ "name": "spring.cloud.aws.sqs.enabled", "description": "Enables SQS integration.", "type": "java.lang.Boolean" + }, + { + "defaultValue": true, + "name": "spring.cloud.aws.appconfig.enabled", + "description": "Enables App Config integration.", + "type": "java.lang.Boolean" } ] } diff --git a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring.factories index ae97226a8a..59019ce26e 100644 --- a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring.factories @@ -2,12 +2,14 @@ org.springframework.boot.context.config.ConfigDataLocationResolver=\ io.awspring.cloud.autoconfigure.config.parameterstore.ParameterStoreConfigDataLocationResolver,\ io.awspring.cloud.autoconfigure.config.secretsmanager.SecretsManagerConfigDataLocationResolver,\ +io.awspring.cloud.autoconfigure.config.appconfig.AppConfigDataLocationResolver,\ io.awspring.cloud.autoconfigure.config.s3.S3ConfigDataLocationResolver # ConfigData Loaders org.springframework.boot.context.config.ConfigDataLoader=\ io.awspring.cloud.autoconfigure.config.parameterstore.ParameterStoreConfigDataLoader,\ io.awspring.cloud.autoconfigure.config.secretsmanager.SecretsManagerConfigDataLoader,\ +io.awspring.cloud.autoconfigure.config.appconfig.AppConfigConfigDataLoader,\ io.awspring.cloud.autoconfigure.config.s3.S3ConfigDataLoader # Failure Analyzers @@ -17,4 +19,6 @@ io.awspring.cloud.autoconfigure.config.secretsmanager.SecretsManagerExceptionHap io.awspring.cloud.autoconfigure.config.parameterstore.ParameterStoreExceptionHappenedAnalyzer, \ io.awspring.cloud.autoconfigure.config.s3.S3ExceptionHappenedAnalyzer, \ io.awspring.cloud.autoconfigure.config.secretsmanager.SecretsManagerMissingKeysFailureAnalyzer,\ -io.awspring.cloud.autoconfigure.config.s3.S3MissingKeysFailureAnalyzer +io.awspring.cloud.autoconfigure.config.s3.S3MissingKeysFailureAnalyzer,\ +io.awspring.cloud.autoconfigure.config.appconfig.AppConfigExceptionHappenedAnalyzer,\ +io.awspring.cloud.autoconfigure.config.appconfig.AppConfigMissingKeysFailureAnalyzer diff --git a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 685d3a75ae..9e088ebd3e 100644 --- a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -15,4 +15,5 @@ io.awspring.cloud.autoconfigure.config.secretsmanager.SecretsManagerReloadAutoCo io.awspring.cloud.autoconfigure.config.secretsmanager.SecretsManagerAutoConfiguration io.awspring.cloud.autoconfigure.config.parameterstore.ParameterStoreReloadAutoConfiguration io.awspring.cloud.autoconfigure.config.parameterstore.ParameterStoreAutoConfiguration +io.awspring.cloud.autoconfigure.config.appconfig.AppConfigReloadAutoConfiguration io.awspring.cloud.autoconfigure.config.s3.S3ReloadAutoConfiguration diff --git a/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java new file mode 100644 index 0000000000..8b8fcd7268 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java @@ -0,0 +1,326 @@ +/* + * Copyright 2013-2022 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 io.awspring.cloud.autoconfigure.config.appconfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.io.ClassPathResource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.localstack.LocalStackContainer; +import org.testcontainers.utility.DockerImageName; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.appconfig.AppConfigClient; +import software.amazon.awssdk.services.appconfig.model.*; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; + +/** + * Integration tests for loading configuration properties from AWS AppConfig. + * + * @author Matej Nedic + */ +@Testcontainers +@ExtendWith(OutputCaptureExtension.class) +@EnabledIfEnvironmentVariable(named = "LOCALSTACK_AUTH_TOKEN", matches = ".+", disabledReason = "Requires LocalStack Pro image") +class AppConfigConfigDataLoaderIntegrationTests { + + private static final String NEW_LINE_CHAR = System.lineSeparator(); + private static final String REGION = "eu-central-1"; + private static AppConfigClient appConfigClient; + private static String APP_ID; + private static String ENV_ID; + private static String STRATEGY_ID; + private static String PROFILE_ID_PROPERTIES; + private static String PROFILE_ID_YAML; + private static String PROFILE_ID_JSON; + private static String IMPORT_PROPERTIES; + private static String IMPORT_YAML; + private static String IMPORT_JSON; + + private static final String api_key = System.getenv("LOCALSTACK_AUTH_TOKEN"); + + @Container + static LocalStackContainer localstack = new LocalStackContainer( + DockerImageName.parse("localstack/localstack-pro:4.4.0")).withEnv("LOCALSTACK_AUTH_TOKEN", api_key) + .withEnv("AWS_DEFAULT_REGION", REGION); + + @BeforeAll + static void beforeAll() throws IOException { + appConfigClient = AppConfigClient.builder().endpointOverride(localstack.getEndpoint()).region(Region.of(REGION)) + .credentialsProvider(StaticCredentialsProvider + .create(AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()))) + .build(); + + CreateApplicationResponse appResponse = appConfigClient.createApplication( + CreateApplicationRequest.builder().name("myApp").description("My Application").build()); + APP_ID = appResponse.id(); + + CreateEnvironmentResponse envResponse = appConfigClient.createEnvironment(CreateEnvironmentRequest.builder() + .applicationId(APP_ID).name("myEnv").description("My Environment").build()); + ENV_ID = envResponse.id(); + + CreateDeploymentStrategyResponse strategyResponse = appConfigClient.createDeploymentStrategy( + CreateDeploymentStrategyRequest.builder().name("myStrategy").description("My Strategy") + .deploymentDurationInMinutes(0).growthFactor(100.0f).finalBakeTimeInMinutes(0).build()); + STRATEGY_ID = strategyResponse.id(); + + PROFILE_ID_PROPERTIES = createProfileWithContent("propertiesProfile", "text/plain", + "io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties"); + + PROFILE_ID_YAML = createProfileWithContent("yamlProfile", "application/x-yaml", + "io/awspring/cloud/autoconfigure/config/appconfig/test-config.yaml"); + + PROFILE_ID_JSON = createProfileWithContent("jsonProfile", "application/json", + "io/awspring/cloud/autoconfigure/config/appconfig/test-config.json"); + + IMPORT_PROPERTIES = "aws-appconfig:" + APP_ID + "#" + PROFILE_ID_PROPERTIES + "#" + ENV_ID; + IMPORT_YAML = "aws-appconfig:" + APP_ID + "#" + PROFILE_ID_YAML + "#" + ENV_ID; + IMPORT_JSON = "aws-appconfig:" + APP_ID + "#" + PROFILE_ID_JSON + "#" + ENV_ID; + } + + private static String createProfileWithContent(String profileName, String contentType, String resourcePath) + throws IOException { + CreateConfigurationProfileResponse profileResponse = appConfigClient + .createConfigurationProfile(CreateConfigurationProfileRequest.builder().applicationId(APP_ID) + .name(profileName).locationUri("hosted").build()); + + ClassPathResource resource = new ClassPathResource(resourcePath); + String content = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + + appConfigClient.createHostedConfigurationVersion(CreateHostedConfigurationVersionRequest.builder() + .applicationId(APP_ID).configurationProfileId(profileResponse.id()) + .content(SdkBytes.fromUtf8String(content)).contentType(contentType).build()); + + appConfigClient.startDeployment(StartDeploymentRequest.builder().applicationId(APP_ID).environmentId(ENV_ID) + .deploymentStrategyId(STRATEGY_ID).configurationProfileId(profileResponse.id()) + .configurationVersion("1").build()); + + return profileResponse.id(); + } + + @Test + void resolvesPropertyFromAppConfig() { + SpringApplication application = createApplication(); + + try (ConfigurableApplicationContext context = runApplication(application, IMPORT_PROPERTIES)) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + assertThat(context.getEnvironment().getProperty("cloud.aws.s3.enabled")).isEqualTo("true"); + assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")).isEqualTo("yes"); + } + } + + @Test + void whenKeysAreNotSpecifiedFailsWithHumanReadableFailureMessage(CapturedOutput output) { + SpringApplication application = createApplication(); + + assertThatThrownBy(() -> runApplication(application, "aws-appconfig:")) + .isInstanceOf(AppConfigKeysMissingException.class); + String errorMessage = "Description:%1$s%1$sCould not import properties from AWS App Config" + .formatted(NEW_LINE_CHAR); + assertThat(output.getOut()).contains(errorMessage); + } + + @Test + void whenKeysCannotBeFoundFailWithHumanReadableMessage(CapturedOutput output) { + SpringApplication application = createApplication(); + + assertThatThrownBy(() -> runApplication(application, "aws-appconfig:invalidApp#invalidProfile#invalidEnv")) + .isInstanceOf(AwsAppConfigPropertySourceNotFoundException.class); + String errorMessage = "Description:%1$s%1$sCould not import properties from App Config. Exception happened while trying to load the keys" + .formatted(NEW_LINE_CHAR); + assertThat(output.getOut()).contains(errorMessage); + } + + @Test + void propertyIsNotResolvedWhenIntegrationIsDisabled() { + SpringApplication application = createApplication(); + + try (ConfigurableApplicationContext context = runApplication(application, IMPORT_PROPERTIES, + "spring.cloud.aws.endpoint", "--spring.cloud.aws.appconfig.enabled=false")) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isNull(); + assertThat(context.getBeanProvider(AppConfigDataClient.class).getIfAvailable()).isNull(); + } + } + + @Test + void customSeparatorIsRespected() { + SpringApplication application = createApplication(); + + try (ConfigurableApplicationContext context = runApplication(application, + "aws-appconfig:" + APP_ID + "/" + PROFILE_ID_PROPERTIES + "/" + ENV_ID, "spring.cloud.aws.endpoint", + "--spring.cloud.aws.appconfig.separator=/")) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + } + } + + @Test + void resolvesPropertiesFromYamlContentType() { + SpringApplication application = createApplication(); + + try (ConfigurableApplicationContext context = runApplication(application, IMPORT_YAML)) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + assertThat(context.getEnvironment().getProperty("cloud.aws.s3.enabled")).isEqualTo("true"); + assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")).isEqualTo("no"); + } + } + + @Test + void resolvesPropertiesFromJsonContentType() { + SpringApplication application = createApplication(); + + try (ConfigurableApplicationContext context = runApplication(application, IMPORT_JSON)) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + assertThat(context.getEnvironment().getProperty("cloud.aws.s3.enabled")).isEqualTo("true"); + assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")).isEqualTo("yes"); + } + } + + @Nested + class ReloadConfigurationTests { + + @AfterEach + void resetConfiguration() throws IOException { + updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, + "io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties"); + } + + @Test + void reloadsProperties() throws IOException { + SpringApplication application = createApplication(); + + try (ConfigurableApplicationContext context = runApplication(application, IMPORT_PROPERTIES, + "spring.cloud.aws.appconfig.endpoint", "--spring.cloud.aws.appconfig.reload.strategy=refresh", + "--spring.cloud.aws.appconfig.reload.period=PT1S")) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + + updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, + "io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties"); + + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("false"); + assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")) + .isEqualTo("updated"); + }); + } + } + + @Test + void doesNotReloadPropertiesWhenMonitoringIsDisabled() throws IOException { + SpringApplication application = createApplication(); + + try (ConfigurableApplicationContext context = runApplication(application, IMPORT_PROPERTIES, + "spring.cloud.aws.appconfig.endpoint", "--spring.cloud.aws.appconfig.reload.period=PT1S")) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + + updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, + "io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties"); + + await().during(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + }); + } + } + + @Test + void reloadsPropertiesWithRestartContextStrategy() throws IOException { + SpringApplication application = createApplication(); + + try (ConfigurableApplicationContext context = runApplication(application, IMPORT_PROPERTIES, + "spring.cloud.aws.appconfig.endpoint", + "--spring.cloud.aws.appconfig.reload.strategy=RESTART_CONTEXT", + "--spring.cloud.aws.appconfig.reload.period=PT1S", + "--spring.cloud.aws.appconfig.reload.max-wait-for-restart=PT1S", + "--management.endpoint.restart.enabled=true", + "--management.endpoints.web.exposure.include=restart")) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + + updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, + "io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties"); + + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("false"); + assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")) + .isEqualTo("updated"); + }); + } + } + + private void updateAppConfigConfiguration(String profileId, String resourcePath) throws IOException { + ClassPathResource resource = new ClassPathResource(resourcePath); + String content = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + + // Create new version + CreateHostedConfigurationVersionResponse versionResponse = appConfigClient + .createHostedConfigurationVersion(CreateHostedConfigurationVersionRequest.builder() + .applicationId(APP_ID).configurationProfileId(profileId) + .content(SdkBytes.fromUtf8String(content)).contentType("text/plain").build()); + + appConfigClient.startDeployment(StartDeploymentRequest.builder().applicationId(APP_ID).environmentId(ENV_ID) + .deploymentStrategyId(STRATEGY_ID).configurationProfileId(profileId) + .configurationVersion(String.valueOf(versionResponse.versionNumber())).build()); + } + } + + private SpringApplication createApplication() { + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + return application; + } + + private ConfigurableApplicationContext runApplication(SpringApplication application, String springConfigImport) { + return runApplication(application, springConfigImport, "spring.cloud.aws.appconfig.endpoint"); + } + + private ConfigurableApplicationContext runApplication(SpringApplication application, String springConfigImport, + String endpointProperty, String... extraArgs) { + List args = new ArrayList<>(List.of("--spring.config.import=" + springConfigImport, + "--spring.cloud.aws.appconfig.region=" + REGION, + "--" + endpointProperty + "=" + localstack.getEndpoint(), + "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), + "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), + "--spring.cloud.aws.region.static=" + REGION, "--logging.level.io.awspring.cloud.appconfig=debug")); + args.addAll(List.of(extraArgs)); + return application.run(args.toArray(String[]::new)); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class App { + } +} diff --git a/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfigurationTests.java b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfigurationTests.java new file mode 100644 index 0000000000..21e0150d09 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfigurationTests.java @@ -0,0 +1,129 @@ +/* + * Copyright 2013-2022 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 io.awspring.cloud.autoconfigure.config.appconfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockingDetails; + +import io.awspring.cloud.appconfig.AppConfigPropertySource; +import io.awspring.cloud.autoconfigure.config.reload.ConfigurationUpdateStrategy; +import io.awspring.cloud.autoconfigure.config.reload.PollingAwsPropertySourceChangeDetector; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cloud.autoconfigure.ConfigurationPropertiesRebinderAutoConfiguration; +import org.springframework.cloud.autoconfigure.RefreshAutoConfiguration; +import org.springframework.cloud.autoconfigure.RefreshEndpointAutoConfiguration; +import org.springframework.cloud.commons.util.TaskSchedulerWrapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; + +/** + * Tests for {@link AppConfigReloadAutoConfiguration}. + * + * @author Matej Nedic + */ +class AppConfigReloadAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.cloud.aws.region.static:eu-west-1").withConfiguration( + AutoConfigurations.of(AppConfigReloadAutoConfiguration.class, RefreshAutoConfiguration.class)); + + @Test + void createsBeansForRefreshStrategy() { + this.contextRunner.withPropertyValues("spring.cloud.aws.appconfig.reload.strategy:refresh") + .run(this::createsBeans); + } + + @Test + void createsBeansForRestartStrategy() { + this.contextRunner + .withConfiguration(AutoConfigurations.of(EndpointAutoConfiguration.class, + RefreshEndpointAutoConfiguration.class, ConfigurationPropertiesRebinderAutoConfiguration.class)) + .withPropertyValues("spring.cloud.aws.appconfig.reload.strategy:restart_context", + "management.endpoint.restart.enabled:true", "management.endpoints.web.exposure.include:restart") + .run(this::createsBeans); + } + + @Test + void doesntCreateBeansWhenStrategyNotSet() { + this.contextRunner.run(this::doesNotCreateBeans); + } + + @Test + void usesCustomTaskScheduler() { + this.contextRunner.withPropertyValues("spring.cloud.aws.appconfig.reload.strategy:refresh") + .withUserConfiguration(CustomTaskSchedulerConfig.class).run(ctx -> { + Object taskScheduler = ctx.getBean("appConfigTaskScheduler"); + assertThat(mockingDetails(taskScheduler).isMock()).isTrue(); + }); + } + + @Test + void usesCustomConfigurationUpdateStrategy() { + this.contextRunner.withPropertyValues("spring.cloud.aws.appconfig.reload.strategy:refresh") + .withUserConfiguration(CustomConfigurationUpdateStrategyConfig.class).run(ctx -> { + ConfigurationUpdateStrategy configurationUpdateStrategy = ctx + .getBean(ConfigurationUpdateStrategy.class); + assertThat(mockingDetails(configurationUpdateStrategy).isMock()).isTrue(); + }); + } + + private void doesNotCreateBeans(AssertableApplicationContext ctx) { + assertThat(ctx).doesNotHaveBean("appConfigConfigurationUpdateStrategy"); + assertThat(ctx).doesNotHaveBean("appConfigPollingAwsPropertySourceChangeDetector"); + assertThat(ctx).doesNotHaveBean("appConfigTaskScheduler"); + } + + private void createsBeans(AssertableApplicationContext ctx) { + assertThat(ctx).hasBean("appConfigConfigurationUpdateStrategy"); + assertThat(ctx.getBean("appConfigConfigurationUpdateStrategy")).isInstanceOf(ConfigurationUpdateStrategy.class); + + assertThat(ctx).hasBean("appConfigPollingAwsPropertySourceChangeDetector"); + assertThat(ctx).getBean("appConfigPollingAwsPropertySourceChangeDetector") + .isInstanceOf(PollingAwsPropertySourceChangeDetector.class); + + PollingAwsPropertySourceChangeDetector changeDetector = ctx + .getBean(PollingAwsPropertySourceChangeDetector.class); + assertThat(changeDetector.getPropertySourceClass()).isEqualTo(AppConfigPropertySource.class); + + assertThat(ctx).getBean("appConfigTaskScheduler").isInstanceOf(TaskSchedulerWrapper.class); + } + + @Configuration(proxyBeanMethods = false) + static class CustomTaskSchedulerConfig { + + @Bean("appConfigTaskScheduler") + public TaskSchedulerWrapper customTaskScheduler() { + return mock(TaskSchedulerWrapper.class, Answers.RETURNS_DEEP_STUBS); + } + } + + @Configuration(proxyBeanMethods = false) + static class CustomConfigurationUpdateStrategyConfig { + + @Bean("appConfigConfigurationUpdateStrategy") + public ConfigurationUpdateStrategy configurationUpdateStrategy() { + return mock(ConfigurationUpdateStrategy.class); + } + } + +} diff --git a/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties b/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties new file mode 100644 index 0000000000..fc72fd9fd0 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties @@ -0,0 +1,3 @@ +cloud.aws.sqs.enabled=false +cloud.aws.s3.enabled=true +some.property.to.be.checked=updated diff --git a/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.json b/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.json new file mode 100644 index 0000000000..fe9a99d36a --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.json @@ -0,0 +1,21 @@ +{ + "cloud": { + "aws": { + "sqs": { + "enabled": true + }, + "s3": { + "enabled": true + } + } + }, + "some": { + "property": { + "to": { + "be": { + "checked": "yes" + } + } + } + } +} diff --git a/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties b/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties new file mode 100644 index 0000000000..634d9446d3 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties @@ -0,0 +1,3 @@ +cloud.aws.sqs.enabled=true +cloud.aws.s3.enabled=true +some.property.to.be.checked=yes diff --git a/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.yaml b/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.yaml new file mode 100644 index 0000000000..62fd06a2db --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.yaml @@ -0,0 +1,11 @@ +cloud: + aws: + sqs: + enabled: true + s3: + enabled: true +some: + property: + to: + be: + checked: "no" diff --git a/spring-cloud-aws-dependencies/pom.xml b/spring-cloud-aws-dependencies/pom.xml index a177da16a5..828acb8d6e 100644 --- a/spring-cloud-aws-dependencies/pom.xml +++ b/spring-cloud-aws-dependencies/pom.xml @@ -105,6 +105,13 @@ ${project.version} + + + io.awspring.cloud + spring-cloud-aws-app-config + ${project.version} + + io.awspring.cloud spring-cloud-aws-parameter-store @@ -137,6 +144,13 @@ spring-cloud-aws-starter-integration-kinesis ${project.version} + + + io.awspring.cloud + spring-cloud-aws-starter-app-config + ${project.version} + + io.awspring.cloud spring-cloud-aws-starter-integration-kinesis-producer diff --git a/spring-cloud-aws-starters/spring-cloud-aws-starter-app-config/pom.xml b/spring-cloud-aws-starters/spring-cloud-aws-starter-app-config/pom.xml new file mode 100644 index 0000000000..10b441261e --- /dev/null +++ b/spring-cloud-aws-starters/spring-cloud-aws-starter-app-config/pom.xml @@ -0,0 +1,26 @@ + + + + spring-cloud-aws + io.awspring.cloud + 4.0.1-SNAPSHOT + ../../pom.xml + + 4.0.0 + + spring-cloud-aws-starter-app-config + Spring Cloud AWS App Config Starter + + + + io.awspring.cloud + spring-cloud-aws-app-config + + + io.awspring.cloud + spring-cloud-aws-starter + + +