diff --git a/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/client/DaprClientProperties.java b/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/client/DaprClientProperties.java index 135416946b..5f7256df5f 100644 --- a/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/client/DaprClientProperties.java +++ b/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/client/DaprClientProperties.java @@ -16,8 +16,10 @@ import io.dapr.spring.data.DaprKeyValueAdapterResolver; import org.springframework.boot.context.properties.ConfigurationProperties; -@ConfigurationProperties(prefix = "dapr.client") +@ConfigurationProperties(prefix = DaprClientProperties.PROPERTY_PREFIX) public class DaprClientProperties { + public static final String PROPERTY_PREFIX = "dapr.client"; + private String httpEndpoint; private String grpcEndpoint; private Integer httpPort; diff --git a/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter/pom.xml b/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter/pom.xml index 623040b378..3a2f73ed6f 100644 --- a/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter/pom.xml +++ b/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter/pom.xml @@ -45,6 +45,11 @@ dapr-spring-workflows ${project.parent.version} + + io.dapr.spring + dapr-spring-cloudconfig + ${project.parent.version} + diff --git a/dapr-spring/dapr-spring-cloudconfig/pom.xml b/dapr-spring/dapr-spring-cloudconfig/pom.xml new file mode 100644 index 0000000000..c77a803032 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/pom.xml @@ -0,0 +1,79 @@ + + + 4.0.0 + + io.dapr.spring + dapr-spring-parent + 0.15.0-SNAPSHOT + + + dapr-spring-cloudconfig + dapr-spring-cloudconfig + Dapr Spring Cloud Config + jar + + + + io.dapr.spring + dapr-spring-boot-autoconfigure + ${project.parent.version} + compile + + + + io.dapr.spring + dapr-spring-data + ${project.parent.version} + test + + + io.dapr.spring + dapr-spring-messaging + ${project.parent.version} + test + + + io.dapr.spring + dapr-spring-workflows + ${project.parent.version} + test + + + org.springframework.boot + spring-boot-starter-web + test + + + org.mockito + mockito-core + test + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.8.2.0 + + ./spotbugs-exclude.xml + ${spotbugs.fail} + true + + + + validate + validate + + check + + + + + + + + \ No newline at end of file diff --git a/dapr-spring/dapr-spring-cloudconfig/spotbugs-exclude.xml b/dapr-spring/dapr-spring-cloudconfig/spotbugs-exclude.xml new file mode 100644 index 0000000000..a3108aba0c --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/spotbugs-exclude.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/autoconfigure/DaprCloudConfigAutoConfiguration.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/autoconfigure/DaprCloudConfigAutoConfiguration.java new file mode 100644 index 0000000000..f3283728bc --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/autoconfigure/DaprCloudConfigAutoConfiguration.java @@ -0,0 +1,29 @@ +/* + * Copyright 2025 The Dapr 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 + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.spring.boot.cloudconfig.autoconfigure; + +import io.dapr.client.DaprClient; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(DaprCloudConfigProperties.class) +@ConditionalOnProperty(name = DaprCloudConfigProperties.PROPERTY_PREFIX + ".enabled", matchIfMissing = true) +@ConditionalOnClass(DaprClient.class) +public class DaprCloudConfigAutoConfiguration { + +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/CloudConfigPropertiesDaprConnectionDetails.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/CloudConfigPropertiesDaprConnectionDetails.java new file mode 100644 index 0000000000..8f56c383ba --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/CloudConfigPropertiesDaprConnectionDetails.java @@ -0,0 +1,46 @@ +/* + * Copyright 2025 The Dapr 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 + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.spring.boot.cloudconfig.config; + +import io.dapr.spring.boot.autoconfigure.client.DaprClientProperties; +import io.dapr.spring.boot.autoconfigure.client.DaprConnectionDetails; + +class CloudConfigPropertiesDaprConnectionDetails implements DaprConnectionDetails { + + private final DaprClientProperties daprClientProperties; + + public CloudConfigPropertiesDaprConnectionDetails(DaprClientProperties daprClientProperties) { + this.daprClientProperties = daprClientProperties; + } + + @Override + public String httpEndpoint() { + return this.daprClientProperties.getHttpEndpoint(); + } + + @Override + public String grpcEndpoint() { + return this.daprClientProperties.getGrpcEndpoint(); + } + + @Override + public Integer httpPort() { + return this.daprClientProperties.getHttpPort(); + } + + @Override + public Integer grpcPort() { + return this.daprClientProperties.getGrpcPort(); + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/DaprCloudConfigClientManager.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/DaprCloudConfigClientManager.java new file mode 100644 index 0000000000..3038a152ce --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/DaprCloudConfigClientManager.java @@ -0,0 +1,90 @@ +/* + * Copyright 2025 The Dapr 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 + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.spring.boot.cloudconfig.config; + +import io.dapr.client.DaprClient; +import io.dapr.client.DaprClientBuilder; +import io.dapr.client.DaprPreviewClient; +import io.dapr.config.Properties; +import io.dapr.spring.boot.autoconfigure.client.DaprClientProperties; +import io.dapr.spring.boot.autoconfigure.client.DaprConnectionDetails; + +public class DaprCloudConfigClientManager { + + private static DaprClient daprClient; + private static DaprPreviewClient daprPreviewClient; + private final DaprCloudConfigProperties daprCloudConfigProperties; + private final DaprClientProperties daprClientConfig; + + /** + * Create a DaprCloudConfigClientManager to create Config-Specific Dapr Client. + * + * @param daprCloudConfigProperties Properties of Dapr Cloud Config + * @param daprClientConfig Properties of Dapr Client + */ + public DaprCloudConfigClientManager(DaprCloudConfigProperties daprCloudConfigProperties, + DaprClientProperties daprClientConfig) { + this.daprCloudConfigProperties = daprCloudConfigProperties; + this.daprClientConfig = daprClientConfig; + + DaprClientBuilder daprClientBuilder = createDaprClientBuilder( + createDaprConnectionDetails(daprClientConfig) + ); + + if (DaprCloudConfigClientManager.daprClient == null) { + DaprCloudConfigClientManager.daprClient = daprClientBuilder.build(); + } + + if (DaprCloudConfigClientManager.daprPreviewClient == null) { + DaprCloudConfigClientManager.daprPreviewClient = daprClientBuilder.buildPreviewClient(); + } + } + + public static DaprPreviewClient getDaprPreviewClient() { + return DaprCloudConfigClientManager.daprPreviewClient; + } + + public static DaprClient getDaprClient() { + return DaprCloudConfigClientManager.daprClient; + } + + private DaprConnectionDetails createDaprConnectionDetails(DaprClientProperties properties) { + return new CloudConfigPropertiesDaprConnectionDetails(properties); + } + + DaprClientBuilder createDaprClientBuilder(DaprConnectionDetails daprConnectionDetails) { + DaprClientBuilder builder = new DaprClientBuilder(); + if (daprConnectionDetails.httpEndpoint() != null) { + builder.withPropertyOverride(Properties.HTTP_ENDPOINT, daprConnectionDetails.httpEndpoint()); + } + if (daprConnectionDetails.grpcEndpoint() != null) { + builder.withPropertyOverride(Properties.GRPC_ENDPOINT, daprConnectionDetails.grpcEndpoint()); + } + if (daprConnectionDetails.httpPort() != null) { + builder.withPropertyOverride(Properties.HTTP_PORT, String.valueOf(daprConnectionDetails.httpPort())); + } + if (daprConnectionDetails.grpcPort() != null) { + builder.withPropertyOverride(Properties.GRPC_PORT, String.valueOf(daprConnectionDetails.grpcPort())); + } + return builder; + } + + public DaprCloudConfigProperties getDaprCloudConfigProperties() { + return daprCloudConfigProperties; + } + + public DaprClientProperties getDaprClientConfig() { + return daprClientConfig; + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/DaprCloudConfigProperties.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/DaprCloudConfigProperties.java new file mode 100644 index 0000000000..7087f91254 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/DaprCloudConfigProperties.java @@ -0,0 +1,77 @@ +/* + * Copyright 2025 The Dapr 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 + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.spring.boot.cloudconfig.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * The properties for creating dapr client. + */ +@ConfigurationProperties(DaprCloudConfigProperties.PROPERTY_PREFIX) +public class DaprCloudConfigProperties { + + public static final String PROPERTY_PREFIX = "dapr.cloudconfig"; + + /** + * whether enable cloud config. + */ + private Boolean enabled = true; + + /** + * whether enable dapr client wait for sidecar, if no response, will throw IOException. + */ + private Boolean waitSidecarEnabled = false; + + /** + * retries of dapr client wait for sidecar. + */ + private Integer waitSidecarRetries = 3; + + /** + * get config timeout (include wait for dapr sidecar). + */ + private Integer timeout = 2000; + + public Integer getTimeout() { + return timeout; + } + + public void setTimeout(Integer timeout) { + this.timeout = timeout; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public Boolean getWaitSidecarEnabled() { + return waitSidecarEnabled; + } + + public void setWaitSidecarEnabled(Boolean waitSidecarEnabled) { + this.waitSidecarEnabled = waitSidecarEnabled; + } + + public Integer getWaitSidecarRetries() { + return waitSidecarRetries; + } + + public void setWaitSidecarRetries(Integer waitSidecarRetries) { + this.waitSidecarRetries = waitSidecarRetries; + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/DaprCloudConfigParserHandler.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/DaprCloudConfigParserHandler.java new file mode 100644 index 0000000000..fdb58fdeb1 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/DaprCloudConfigParserHandler.java @@ -0,0 +1,141 @@ +/* + * Copyright 2025 The Dapr 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 + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.spring.boot.cloudconfig.configdata; + +import io.dapr.spring.boot.cloudconfig.configdata.types.DaprCloudConfigType; +import io.dapr.spring.boot.cloudconfig.configdata.types.DocType; +import org.springframework.boot.env.PropertySourceLoader; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class DaprCloudConfigParserHandler { + + private static List propertySourceLoaders; + + private DaprCloudConfigParserHandler() { + List loaders = SpringFactoriesLoader + .loadFactories(PropertySourceLoader.class, getClass().getClassLoader()); + + //Range loaders (Yaml as the first) + int yamlIndex = -1; + for (int i = 0; i < loaders.size(); i++) { + if (loaders.get(i) instanceof YamlPropertySourceLoader) { + yamlIndex = i; + break; + } + } + + // found yaml loader then move to the front + if (yamlIndex != -1) { + PropertySourceLoader yamlSourceLoader = loaders.remove(yamlIndex); + loaders.add(0, yamlSourceLoader); + } + + propertySourceLoaders = loaders; + } + + public static DaprCloudConfigParserHandler getInstance() { + return ParserHandler.HANDLER; + } + + /** + * Parse Secret using PropertySourceLoaders. + * + *

+ * if type = doc, will treat all values as a property source (both "properties" or "yaml" format supported) + *

+ * + *

+ * if type = value, will transform key and value to "key=value" format ("properties" format) + *

+ * + * @param configName name of the config + * @param configValue value of the config + * @param type value type + * @return property source list + */ + public List> parseDaprCloudConfigData( + String configName, + Map configValue, + DaprCloudConfigType type + ) { + List> result = new ArrayList<>(); + + Map configResults = getConfigResult(configValue, type); + String extension = type instanceof DocType ? ((DocType) type).getDocExtension() : ".properties"; + + configResults.forEach((key, configResult) -> { + for (PropertySourceLoader propertySourceLoader : propertySourceLoaders) { + if (!canLoadFileExtension(propertySourceLoader, extension)) { + continue; + } + String fullConfigName = StringUtils.hasText(key) ? configName + "." + key : configName; + try { + result.addAll(propertySourceLoader.load(fullConfigName, configResult)); + } catch (IOException ignored) { + continue; + } + return; + } + }); + + return result; + } + + private Map getConfigResult( + Map configValue, + DaprCloudConfigType type + ) { + Map result = new HashMap<>(); + if (type instanceof DocType) { + configValue.forEach((key, value) -> result.put(key, + new ByteArrayResource(value.getBytes(StandardCharsets.UTF_8)))); + } else { + List configList = new ArrayList<>(); + configValue.forEach((key, value) -> configList.add(String.format("%s=%s", key, value))); + result.put("", new ByteArrayResource(String.join("\n", configList).getBytes(StandardCharsets.UTF_8))); + } + return result; + } + + /** + * check the current extension can be processed. + * @param loader the propertySourceLoader + * @param extension file extension + * @return if can match extension + */ + private boolean canLoadFileExtension(PropertySourceLoader loader, String extension) { + return Arrays.stream(loader.getFileExtensions()) + .anyMatch((fileExtension) -> StringUtils.endsWithIgnoreCase(extension, + fileExtension)); + } + + private static class ParserHandler { + + private static final DaprCloudConfigParserHandler HANDLER = new DaprCloudConfigParserHandler(); + + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataLoader.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataLoader.java new file mode 100644 index 0000000000..c97b6c8cd0 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataLoader.java @@ -0,0 +1,150 @@ +/* + * Copyright 2025 The Dapr 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 + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.spring.boot.cloudconfig.configdata.config; + +import io.dapr.client.DaprClient; +import io.dapr.client.domain.ConfigurationItem; +import io.dapr.client.domain.GetConfigurationRequest; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigClientManager; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties; +import io.dapr.spring.boot.cloudconfig.configdata.DaprCloudConfigParserHandler; +import org.apache.commons.logging.Log; +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.context.config.ConfigDataResourceNotFoundException; +import org.springframework.boot.logging.DeferredLogFactory; +import org.springframework.core.env.PropertySource; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.springframework.boot.context.config.ConfigData.Option.IGNORE_IMPORTS; +import static org.springframework.boot.context.config.ConfigData.Option.IGNORE_PROFILES; +import static org.springframework.boot.context.config.ConfigData.Option.PROFILE_SPECIFIC; + +public class DaprConfigurationConfigDataLoader implements ConfigDataLoader { + + private final Log log; + + private DaprClient daprClient; + + private DaprCloudConfigProperties daprCloudConfigProperties; + + /** + * Create a Config Data Loader to load config from Dapr Configuration api. + * + * @param logFactory logFactory + * @param daprClient Dapr Client created + * @param daprCloudConfigProperties Dapr Cloud Config Properties + */ + public DaprConfigurationConfigDataLoader(DeferredLogFactory logFactory, DaprClient daprClient, + DaprCloudConfigProperties daprCloudConfigProperties) { + this.log = logFactory.getLog(getClass()); + this.daprClient = daprClient; + this.daprCloudConfigProperties = daprCloudConfigProperties; + } + + + /** + * Load {@link ConfigData} for the given resource. + * + * @param context the loader context + * @param resource the resource to load + * @return the loaded config data or {@code null} if the location should be skipped + * @throws IOException on IO error + * @throws ConfigDataResourceNotFoundException if the resource cannot be found + */ + @Override + public ConfigData load(ConfigDataLoaderContext context, DaprConfigurationConfigDataResource resource) + throws IOException, ConfigDataResourceNotFoundException { + DaprCloudConfigClientManager daprClientSecretStoreConfigManager = + getBean(context, DaprCloudConfigClientManager.class); + + daprClient = DaprCloudConfigClientManager.getDaprClient(); + daprCloudConfigProperties = daprClientSecretStoreConfigManager.getDaprCloudConfigProperties(); + + if (!daprCloudConfigProperties.getEnabled()) { + return ConfigData.EMPTY; + } + + if (daprCloudConfigProperties.getWaitSidecarEnabled()) { + waitForSidecar(); + } + + return fetchConfig(resource); + } + + private void waitForSidecar() throws IOException { + try { + daprClient.waitForSidecar(daprCloudConfigProperties.getTimeout()) + .retry(daprCloudConfigProperties.getWaitSidecarRetries()) + .block(); + } catch (RuntimeException e) { + log.info(e.getMessage(), e); + throw new IOException("Failed to wait for sidecar", e); + } + } + + private ConfigData fetchConfig(DaprConfigurationConfigDataResource resource) + throws IOException, ConfigDataResourceNotFoundException { + Mono> secretMapMono = daprClient.getConfiguration(new GetConfigurationRequest( + resource.getStoreName(), + StringUtils.hasText(resource.getConfigName()) + ? List.of(resource.getConfigName()) + : null + )); + + try { + Map secretMap = + secretMapMono.block(Duration.ofMillis(daprCloudConfigProperties.getTimeout())); + + if (secretMap == null) { + log.info("Config not found"); + throw new ConfigDataResourceNotFoundException(resource); + } + + Map configMap = new HashMap<>(); + secretMap.forEach((key, value) -> { + configMap.put(value.getKey(), value.getValue()); + }); + + List> sourceList = + new ArrayList<>(DaprCloudConfigParserHandler.getInstance().parseDaprCloudConfigData( + resource.getStoreName(), + configMap, + resource.getType() + )); + + return new ConfigData(sourceList, IGNORE_IMPORTS, IGNORE_PROFILES, PROFILE_SPECIFIC); + } catch (RuntimeException e) { + log.info("Failed to get config from sidecar: " + e.getMessage(), e); + throw new IOException("Failed to get config from sidecar", e); + } + + } + + protected T getBean(ConfigDataLoaderContext context, Class type) { + if (context.getBootstrapContext().isRegistered(type)) { + return context.getBootstrapContext().get(type); + } + return null; + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataLocationResolver.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataLocationResolver.java new file mode 100644 index 0000000000..a650064696 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataLocationResolver.java @@ -0,0 +1,187 @@ +/* + * Copyright 2025 The Dapr 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 + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.spring.boot.cloudconfig.configdata.config; + +import io.dapr.spring.boot.autoconfigure.client.DaprClientProperties; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigClientManager; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties; +import io.dapr.spring.boot.cloudconfig.configdata.types.DaprCloudConfigType; +import org.apache.commons.logging.Log; +import org.springframework.boot.BootstrapRegistry; +import org.springframework.boot.ConfigurableBootstrapContext; +import org.springframework.boot.context.config.ConfigDataLocation; +import org.springframework.boot.context.config.ConfigDataLocationNotFoundException; +import org.springframework.boot.context.config.ConfigDataLocationResolver; +import org.springframework.boot.context.config.ConfigDataLocationResolverContext; +import org.springframework.boot.context.config.ConfigDataResource; +import org.springframework.boot.context.config.ConfigDataResourceNotFoundException; +import org.springframework.boot.context.properties.bind.BindHandler; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.logging.DeferredLogFactory; +import org.springframework.core.Ordered; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.ArrayList; +import java.util.List; + +public class DaprConfigurationConfigDataLocationResolver + implements ConfigDataLocationResolver, Ordered { + + public static final String PREFIX = "dapr:config:"; + + private final Log log; + + public DaprConfigurationConfigDataLocationResolver(DeferredLogFactory logFactory) { + this.log = logFactory.getLog(getClass()); + } + + /** + * Returns if the specified location address contains dapr prefix. + * + * @param context the location resolver context + * @param location the location to check. + * @return if the location is supported by this resolver + */ + @Override + public boolean isResolvable(ConfigDataLocationResolverContext context, ConfigDataLocation location) { + log.debug(String.format("checking if %s suits for dapr config", location.toString())); + return location.hasPrefix(PREFIX); + } + + /** + * Resolve a {@link ConfigDataLocation} into one or more {@link ConfigDataResource} instances. + * + * @param context the location resolver context + * @param location the location that should be resolved + * @return a list of {@link ConfigDataResource resources} in ascending priority order. + * @throws ConfigDataLocationNotFoundException on a non-optional location that cannot be found + * @throws ConfigDataResourceNotFoundException if a resolved resource cannot be found + */ + @Override + public List resolve(ConfigDataLocationResolverContext context, + ConfigDataLocation location) + throws ConfigDataLocationNotFoundException, ConfigDataResourceNotFoundException { + + DaprCloudConfigProperties daprSecretStoreConfig = loadProperties(context); + DaprClientProperties daprClientConfig = loadClientProperties(context); + + ConfigurableBootstrapContext bootstrapContext = context + .getBootstrapContext(); + + registerConfigManager(daprSecretStoreConfig, daprClientConfig, bootstrapContext); + + List result = new ArrayList<>(); + + // To avoid UriComponentsBuilder to decode a wrong host. + String fullConfig = "config://" + location.getNonPrefixedValue(PREFIX); + + UriComponents configUri = UriComponentsBuilder.fromUriString(fullConfig).build(); + + String storeName = configUri.getHost(); + + String configPath = configUri.getPath(); + String configName = StringUtils.hasText(configPath) + ? StringUtils.trimLeadingCharacter(configPath, '/') + : null; + + MultiValueMap configQuery = configUri.getQueryParams(); + DaprCloudConfigType configType = DaprCloudConfigType.fromString(configQuery.getFirst("type"), + configQuery.getFirst("doc-type")); + Boolean subscribe = StringUtils.hasText(configQuery.getFirst("subscribe")) + && Boolean.parseBoolean(configQuery.getFirst("subscribe")); + + + if (configName == null) { + log.debug("Dapr Cloud Config now gains store name: '" + storeName + "' configuration for config"); + result.add(new DaprConfigurationConfigDataResource(location.isOptional(), storeName, + null, configType, subscribe)); + + } else if (configName.contains("/")) { + throw new ConfigDataLocationNotFoundException(location); + + } else { + log.debug("Dapr Cloud Config now gains store name: '" + storeName + "' and config name: '" + + configName + "' configuration for config"); + result.add( + new DaprConfigurationConfigDataResource(location.isOptional(), storeName, configName, + configType, subscribe)); + + } + + return result; + } + + @Override + public int getOrder() { + return -1; + } + + private void registerConfigManager(DaprCloudConfigProperties properties, + DaprClientProperties clientConfig, + ConfigurableBootstrapContext bootstrapContext) { + synchronized (DaprCloudConfigClientManager.class) { + if (!bootstrapContext.isRegistered(DaprCloudConfigClientManager.class)) { + bootstrapContext.register(DaprCloudConfigClientManager.class, + BootstrapRegistry.InstanceSupplier + .of(new DaprCloudConfigClientManager(properties, clientConfig))); + } + } + } + + protected DaprCloudConfigProperties loadProperties( + ConfigDataLocationResolverContext context) { + Binder binder = context.getBinder(); + BindHandler bindHandler = getBindHandler(context); + + DaprCloudConfigProperties daprCloudConfigProperties; + if (context.getBootstrapContext().isRegistered(DaprCloudConfigProperties.class)) { + daprCloudConfigProperties = context.getBootstrapContext() + .get(DaprCloudConfigProperties.class); + } else { + daprCloudConfigProperties = binder + .bind(DaprCloudConfigProperties.PROPERTY_PREFIX, Bindable.of(DaprCloudConfigProperties.class), + bindHandler) + .orElseGet(DaprCloudConfigProperties::new); + } + + return daprCloudConfigProperties; + } + + protected DaprClientProperties loadClientProperties( + ConfigDataLocationResolverContext context) { + Binder binder = context.getBinder(); + BindHandler bindHandler = getBindHandler(context); + + DaprClientProperties daprClientConfig; + if (context.getBootstrapContext().isRegistered(DaprClientProperties.class)) { + daprClientConfig = context.getBootstrapContext() + .get(DaprClientProperties.class); + } else { + daprClientConfig = binder + .bind(DaprClientProperties.PROPERTY_PREFIX, Bindable.of(DaprClientProperties.class), + bindHandler) + .orElseGet(DaprClientProperties::new); + } + + return daprClientConfig; + } + + private BindHandler getBindHandler(ConfigDataLocationResolverContext context) { + return context.getBootstrapContext().getOrElse(BindHandler.class, null); + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataResource.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataResource.java new file mode 100644 index 0000000000..5d94cb10b9 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataResource.java @@ -0,0 +1,69 @@ +/* + * Copyright 2025 The Dapr 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 + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.spring.boot.cloudconfig.configdata.config; + +import io.dapr.spring.boot.cloudconfig.configdata.types.DaprCloudConfigType; +import org.springframework.boot.context.config.ConfigDataResource; +import org.springframework.lang.Nullable; + +public class DaprConfigurationConfigDataResource extends ConfigDataResource { + private final String storeName; + private final String configName; + private final DaprCloudConfigType type; + private final Boolean subscribe; + + /** + * Create a new non-optional {@link ConfigDataResource} instance. + * @param storeName store name + * @param configName config name + * @param type value type + * @param subscribe subscribe for update + */ + public DaprConfigurationConfigDataResource(String storeName, @Nullable String configName, + DaprCloudConfigType type, Boolean subscribe) { + this.storeName = storeName; + this.configName = configName; + this.type = type; + this.subscribe = subscribe; + } + + /** + * Create a new {@link ConfigDataResource} instance. + * @param optional if the resource is optional + * @param storeName store name + * @param configName config name + * @param type value type + * @param subscribe subscribe for update + */ + public DaprConfigurationConfigDataResource(boolean optional, String storeName, @Nullable String configName, + DaprCloudConfigType type, Boolean subscribe) { + super(optional); + this.storeName = storeName; + this.configName = configName; + this.type = type; + this.subscribe = subscribe; + } + + public String getStoreName() { + return storeName; + } + + public String getConfigName() { + return configName; + } + + public DaprCloudConfigType getType() { + return type; + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataLoader.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataLoader.java new file mode 100644 index 0000000000..aaa094a74f --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataLoader.java @@ -0,0 +1,190 @@ +/* + * Copyright 2025 The Dapr 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 + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.spring.boot.cloudconfig.configdata.secret; + +import io.dapr.client.DaprClient; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigClientManager; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties; +import io.dapr.spring.boot.cloudconfig.configdata.DaprCloudConfigParserHandler; +import org.apache.commons.logging.Log; +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.context.config.ConfigDataResourceNotFoundException; +import org.springframework.boot.logging.DeferredLogFactory; +import org.springframework.core.env.PropertySource; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.springframework.boot.context.config.ConfigData.Option.IGNORE_IMPORTS; +import static org.springframework.boot.context.config.ConfigData.Option.IGNORE_PROFILES; +import static org.springframework.boot.context.config.ConfigData.Option.PROFILE_SPECIFIC; + +public class DaprSecretStoreConfigDataLoader implements ConfigDataLoader { + + private final Log log; + + private DaprClient daprClient; + + private DaprCloudConfigProperties daprCloudConfigProperties; + + /** + * Create a Config Data Loader to load config from Dapr Secret Store api. + * + * @param logFactory logFactory + * @param daprClient Dapr Client created + * @param daprCloudConfigProperties Dapr Cloud Config Properties + */ + public DaprSecretStoreConfigDataLoader(DeferredLogFactory logFactory, DaprClient daprClient, + DaprCloudConfigProperties daprCloudConfigProperties) { + this.log = logFactory.getLog(getClass()); + this.daprClient = daprClient; + this.daprCloudConfigProperties = daprCloudConfigProperties; + } + + + /** + * Load {@link ConfigData} for the given resource. + * + * @param context the loader context + * @param resource the resource to load + * @return the loaded config data or {@code null} if the location should be skipped + * @throws IOException on IO error + * @throws ConfigDataResourceNotFoundException if the resource cannot be found + */ + @Override + public ConfigData load(ConfigDataLoaderContext context, DaprSecretStoreConfigDataResource resource) + throws IOException, ConfigDataResourceNotFoundException { + DaprCloudConfigClientManager daprCloudConfigClientManager = + getBean(context, DaprCloudConfigClientManager.class); + + daprClient = DaprCloudConfigClientManager.getDaprClient(); + daprCloudConfigProperties = daprCloudConfigClientManager.getDaprCloudConfigProperties(); + + if (!daprCloudConfigProperties.getEnabled()) { + return ConfigData.EMPTY; + } + + if (daprCloudConfigProperties.getWaitSidecarEnabled()) { + waitForSidecar(); + } + + if (resource.getSecretName() == null) { + return fetchBulkSecret(resource); + } else { + return fetchSecret(resource); + } + } + + private void waitForSidecar() throws IOException { + try { + daprClient.waitForSidecar(daprCloudConfigProperties.getTimeout()) + .retry(daprCloudConfigProperties.getWaitSidecarRetries()) + .block(); + } catch (RuntimeException e) { + log.info("Failed to wait for sidecar: " + e.getMessage(), e); + throw new IOException("Failed to wait for sidecar", e); + } + } + + /** + * Get Bulk Secret from Store. + * @param resource Secret Data Resource to fetch + * @return config data + * @throws IOException for block returns exception + * @throws ConfigDataResourceNotFoundException for secret not found + */ + private ConfigData fetchBulkSecret(DaprSecretStoreConfigDataResource resource) + throws IOException, ConfigDataResourceNotFoundException { + + Mono>> secretMapMono = daprClient.getBulkSecret(resource.getStoreName()); + + try { + Map> secretMap = + secretMapMono.block(Duration.ofMillis(daprCloudConfigProperties.getTimeout())); + + if (secretMap == null) { + log.info("Secret not found"); + throw new ConfigDataResourceNotFoundException(resource); + } + + List> sourceList = new ArrayList<>(); + + for (Map.Entry> entry : secretMap.entrySet()) { + sourceList.addAll(DaprCloudConfigParserHandler.getInstance().parseDaprCloudConfigData( + resource.getStoreName() + ":" + entry.getKey(), + entry.getValue(), + resource.getType() + )); + } + + log.debug(String.format("now gain %d data source in secret, storename = %s", + sourceList.size(), resource.getStoreName())); + return new ConfigData(sourceList, IGNORE_IMPORTS, IGNORE_PROFILES, PROFILE_SPECIFIC); + } catch (RuntimeException e) { + log.info("Failed to get secret from sidecar: " + e.getMessage(), e); + throw new IOException("Failed to get secret from sidecar", e); + } + } + + /** + * Get Secret from Store. + * @param resource Secret Data Resource to fetch + * @return config data + * @throws IOException for block returns exception + * @throws ConfigDataResourceNotFoundException for secret not found + */ + private ConfigData fetchSecret(DaprSecretStoreConfigDataResource resource) + throws IOException, ConfigDataResourceNotFoundException { + Mono> secretMapMono = daprClient.getSecret(resource.getStoreName(), resource.getSecretName()); + + try { + Map secretMap = secretMapMono.block(Duration.ofMillis(daprCloudConfigProperties.getTimeout())); + + if (secretMap == null) { + log.info("Secret not found"); + throw new ConfigDataResourceNotFoundException(resource); + } + + log.debug(String.format("now gain %d secretMap in secret, storename = %s, secretname = %s", + secretMap.size(), resource.getStoreName(), resource.getSecretName())); + + List> sourceList = new ArrayList<>( + DaprCloudConfigParserHandler.getInstance().parseDaprCloudConfigData( + resource.getStoreName() + ":" + resource.getSecretName(), + secretMap, + resource.getType() + )); + + log.debug(String.format("now gain %d data source in secret, storename = %s, secretname = %s", + sourceList.size(), resource.getStoreName(), resource.getSecretName())); + return new ConfigData(sourceList, IGNORE_IMPORTS, IGNORE_PROFILES, PROFILE_SPECIFIC); + } catch (RuntimeException e) { + log.info("Failed to get secret from sidecar: " + e.getMessage(), e); + throw new IOException("Failed to get secret from sidecar", e); + } + } + + protected T getBean(ConfigDataLoaderContext context, Class type) { + if (context.getBootstrapContext().isRegistered(type)) { + return context.getBootstrapContext().get(type); + } + return null; + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataLocationResolver.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataLocationResolver.java new file mode 100644 index 0000000000..41070ea341 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataLocationResolver.java @@ -0,0 +1,179 @@ +/* + * Copyright 2025 The Dapr 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 + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.spring.boot.cloudconfig.configdata.secret; + +import io.dapr.spring.boot.autoconfigure.client.DaprClientProperties; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigClientManager; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties; +import io.dapr.spring.boot.cloudconfig.configdata.types.DaprCloudConfigType; +import org.apache.commons.logging.Log; +import org.springframework.boot.BootstrapRegistry; +import org.springframework.boot.ConfigurableBootstrapContext; +import org.springframework.boot.context.config.ConfigDataLocation; +import org.springframework.boot.context.config.ConfigDataLocationNotFoundException; +import org.springframework.boot.context.config.ConfigDataLocationResolver; +import org.springframework.boot.context.config.ConfigDataLocationResolverContext; +import org.springframework.boot.context.config.ConfigDataResource; +import org.springframework.boot.context.config.ConfigDataResourceNotFoundException; +import org.springframework.boot.context.properties.bind.BindHandler; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.logging.DeferredLogFactory; +import org.springframework.core.Ordered; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.ArrayList; +import java.util.List; + +public class DaprSecretStoreConfigDataLocationResolver + implements ConfigDataLocationResolver, Ordered { + + public static final String PREFIX = "dapr:secret:"; + + private final Log log; + + public DaprSecretStoreConfigDataLocationResolver(DeferredLogFactory logFactory) { + this.log = logFactory.getLog(getClass()); + } + + /** + * Returns if the specified location address contains dapr prefix. + * + * @param context the location resolver context + * @param location the location to check. + * @return if the location is supported by this resolver + */ + @Override + public boolean isResolvable(ConfigDataLocationResolverContext context, ConfigDataLocation location) { + log.debug(String.format("checking if %s suits for dapr secret", location.toString())); + return location.hasPrefix(PREFIX); + } + + /** + * Resolve a {@link ConfigDataLocation} into one or more {@link ConfigDataResource} instances. + * + * @param context the location resolver context + * @param location the location that should be resolved + * @return a list of {@link ConfigDataResource resources} in ascending priority order. + * @throws ConfigDataLocationNotFoundException on a non-optional location that cannot be found + * @throws ConfigDataResourceNotFoundException if a resolved resource cannot be found + */ + @Override + public List resolve(ConfigDataLocationResolverContext context, + ConfigDataLocation location) + throws ConfigDataLocationNotFoundException, ConfigDataResourceNotFoundException { + + DaprCloudConfigProperties daprSecretStoreConfig = loadProperties(context); + DaprClientProperties daprClientConfig = loadClientProperties(context); + + ConfigurableBootstrapContext bootstrapContext = context + .getBootstrapContext(); + + registerConfigManager(daprSecretStoreConfig, daprClientConfig, bootstrapContext); + + List result = new ArrayList<>(); + + // To avoid UriComponentsBuilder to decode a wrong host. + String fullConfig = "secret://" + location.getNonPrefixedValue(PREFIX); + + UriComponents configUri = UriComponentsBuilder.fromUriString(fullConfig).build(); + + String storeName = configUri.getHost(); + String secretPath = configUri.getPath(); + String secretName = StringUtils.hasText(secretPath) + ? StringUtils.trimLeadingCharacter(secretPath, '/') + : null; + + + MultiValueMap typeQuery = configUri.getQueryParams(); + DaprCloudConfigType secretType = DaprCloudConfigType.fromString(typeQuery.getFirst("type"), + typeQuery.getFirst("doc-type")); + + if (secretName == null) { + log.debug("Dapr Secret Store now gains store name: '" + storeName + "' secret store for config"); + result.add(new DaprSecretStoreConfigDataResource(location.isOptional(), storeName, null, secretType)); + } else if (secretName.contains("/")) { + throw new ConfigDataLocationNotFoundException(location); + } else { + log.debug("Dapr Secret Store now gains store name: '" + storeName + "' and secret name: '" + + secretName + "' secret store for config"); + result.add( + new DaprSecretStoreConfigDataResource(location.isOptional(), storeName, secretName, secretType)); + } + + return result; + } + + @Override + public int getOrder() { + return -1; + } + + private void registerConfigManager(DaprCloudConfigProperties properties, + DaprClientProperties clientConfig, + ConfigurableBootstrapContext bootstrapContext) { + synchronized (DaprCloudConfigClientManager.class) { + if (!bootstrapContext.isRegistered(DaprCloudConfigClientManager.class)) { + bootstrapContext.register(DaprCloudConfigClientManager.class, + BootstrapRegistry.InstanceSupplier + .of(new DaprCloudConfigClientManager(properties, clientConfig))); + } + } + } + + protected DaprCloudConfigProperties loadProperties( + ConfigDataLocationResolverContext context) { + Binder binder = context.getBinder(); + BindHandler bindHandler = getBindHandler(context); + + DaprCloudConfigProperties daprCloudConfigProperties; + if (context.getBootstrapContext().isRegistered(DaprCloudConfigProperties.class)) { + daprCloudConfigProperties = context.getBootstrapContext() + .get(DaprCloudConfigProperties.class); + } else { + daprCloudConfigProperties = binder + .bind(DaprCloudConfigProperties.PROPERTY_PREFIX, Bindable.of(DaprCloudConfigProperties.class), + bindHandler) + .orElseGet(DaprCloudConfigProperties::new); + } + + return daprCloudConfigProperties; + } + + protected DaprClientProperties loadClientProperties( + ConfigDataLocationResolverContext context) { + Binder binder = context.getBinder(); + BindHandler bindHandler = getBindHandler(context); + + DaprClientProperties daprClientConfig; + if (context.getBootstrapContext().isRegistered(DaprClientProperties.class)) { + daprClientConfig = context.getBootstrapContext() + .get(DaprClientProperties.class); + } else { + daprClientConfig = binder + .bind(DaprClientProperties.PROPERTY_PREFIX, Bindable.of(DaprClientProperties.class), + bindHandler) + .orElseGet(DaprClientProperties::new); + } + + return daprClientConfig; + } + + private BindHandler getBindHandler(ConfigDataLocationResolverContext context) { + return context.getBootstrapContext().getOrElse(BindHandler.class, null); + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataResource.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataResource.java new file mode 100644 index 0000000000..c87fee75cd --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataResource.java @@ -0,0 +1,74 @@ +/* + * Copyright 2025 The Dapr 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 + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.spring.boot.cloudconfig.configdata.secret; + +import io.dapr.spring.boot.cloudconfig.configdata.types.DaprCloudConfigType; +import org.springframework.boot.context.config.ConfigDataResource; +import org.springframework.lang.Nullable; + +public class DaprSecretStoreConfigDataResource extends ConfigDataResource { + private final String storeName; + private final String secretName; + private final DaprCloudConfigType type; + + /** + * Create a new non-optional {@link ConfigDataResource} instance. + * + * @param storeName store name + * @param secretName secret name + * @param type secret type + */ + public DaprSecretStoreConfigDataResource(String storeName, @Nullable String secretName, DaprCloudConfigType type) { + this.storeName = storeName; + this.secretName = secretName; + this.type = type; + } + + /** + * Create a new {@link ConfigDataResource} instance. + * + * @param optional if the resource is optional + * @param storeName store name + * @param secretName secret name + * @param type secret type + */ + public DaprSecretStoreConfigDataResource(boolean optional, String storeName, + @Nullable String secretName, DaprCloudConfigType type) { + super(optional); + this.storeName = storeName; + this.secretName = secretName; + this.type = type; + } + + public String getStoreName() { + return storeName; + } + + public String getSecretName() { + return secretName; + } + + public DaprCloudConfigType getType() { + return type; + } + + @Override + public String toString() { + return "DaprSecretStoreConfigDataResource{" + + "storeName='" + storeName + '\'' + + ", secretName='" + secretName + '\'' + + ", type=" + type + + '}'; + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/DaprCloudConfigType.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/DaprCloudConfigType.java new file mode 100644 index 0000000000..527f5cfe20 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/DaprCloudConfigType.java @@ -0,0 +1,17 @@ +package io.dapr.spring.boot.cloudconfig.configdata.types; + +import org.springframework.util.StringUtils; + +public class DaprCloudConfigType { + /** + * Get Type from String. + * @param value type specified in schema + * @param docType type of doc (if specified) + * @return type enum + */ + public static DaprCloudConfigType fromString(String value, String docType) { + return "doc".equals(value) + ? new DocType(StringUtils.hasText(docType) ? docType : "properties") + : new ValueType(); + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/DocType.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/DocType.java new file mode 100644 index 0000000000..31928c627d --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/DocType.java @@ -0,0 +1,20 @@ +package io.dapr.spring.boot.cloudconfig.configdata.types; + +import org.springframework.util.StringUtils; + +public class DocType extends DaprCloudConfigType { + private final String docType; + + public DocType(String docType) { + this.docType = StringUtils.hasText(docType) ? docType : "properties"; + } + + public String getDocType() { + return docType; + } + + public String getDocExtension() { + String type = getDocType(); + return "." + StringUtils.trimLeadingCharacter(type, '.'); + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/ValueType.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/ValueType.java new file mode 100644 index 0000000000..bd1f68d519 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/ValueType.java @@ -0,0 +1,4 @@ +package io.dapr.spring.boot.cloudconfig.configdata.types; + +public class ValueType extends DaprCloudConfigType { +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000000..928939cb5e --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,32 @@ +{ + "properties": [ + { + "name": "dapr.cloudconfig.enabled", + "type": "java.lang.Boolean", + "defaultValue": true, + "sourceType": "io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties", + "description": "enable dapr cloud config or not." + }, + { + "name": "dapr.cloudconfig.timeout", + "type": "java.lang.Integer", + "defaultValue": 2000, + "sourceType": "io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties", + "description": "timeout for getting dapr config (include wait for dapr sidecar)." + }, + { + "name": "dapr.cloudconfig.wait-sidecar-enabled", + "type": "java.lang.Boolean", + "defaultValue": false, + "sourceType": "io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties", + "description": "whether enable dapr client wait for sidecar, if no response, will throw IOException." + }, + { + "name": "dapr.cloudconfig.wait-sidecar-retries", + "type": "java.lang.Integer", + "defaultValue": 3, + "sourceType": "io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties", + "description": "retries of dapr client wait for sidecar." + } + ] +} \ No newline at end of file diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/spring.factories b/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000000..3050977cc1 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/spring.factories @@ -0,0 +1,9 @@ +# ConfigData Location Resolvers +org.springframework.boot.context.config.ConfigDataLocationResolver=\ + io.dapr.spring.boot.cloudconfig.configdata.secret.DaprSecretStoreConfigDataLocationResolver,\ + io.dapr.spring.boot.cloudconfig.configdata.config.DaprConfigurationConfigDataLocationResolver + +# ConfigData Loaders +org.springframework.boot.context.config.ConfigDataLoader=\ + io.dapr.spring.boot.cloudconfig.configdata.secret.DaprSecretStoreConfigDataLoader,\ + io.dapr.spring.boot.cloudconfig.configdata.config.DaprConfigurationConfigDataLoader diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..068c69bd08 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +io.dapr.spring.boot.cloudconfig.autoconfigure.DaprCloudConfigAutoConfiguration diff --git a/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/CloudConfigTestApplication.java b/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/CloudConfigTestApplication.java new file mode 100644 index 0000000000..a69b67a408 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/CloudConfigTestApplication.java @@ -0,0 +1,14 @@ +package io.dapr.spring.boot.cloudconfig; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; + +@SpringBootApplication +@ComponentScan(basePackages = "io.dapr.spring.boot.cloudconfig.config") +public class CloudConfigTestApplication { + public static void main(String[] args) { + SpringApplication.run(CloudConfigTestApplication.class, args); + } + +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/CloudConfigTests.java b/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/CloudConfigTests.java new file mode 100644 index 0000000000..d290ff3633 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/CloudConfigTests.java @@ -0,0 +1,79 @@ +package io.dapr.spring.boot.cloudconfig; + +import io.dapr.client.DaprClient; +import io.dapr.client.domain.ConfigurationItem; +import io.dapr.client.domain.GetConfigurationRequest; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigClientManager; +import io.dapr.spring.boot.cloudconfig.config.MultipleConfig; +import io.dapr.spring.boot.cloudconfig.config.SingleConfig; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(classes = {CloudConfigTestApplication.class, MultipleConfig.class, SingleConfig.class}) +public class CloudConfigTests { + + static { + try { + DaprClient daprClient = Mockito.mock(DaprClient.class); + Mockito.when(daprClient.waitForSidecar(Mockito.anyInt())).thenReturn(Mono.empty()); + + Map multiValueProperties = new HashMap<>(); + multiValueProperties.put("multivalue-properties", "dapr.spring.democonfigsecret.multivalue.v1=spring\ndapr.spring.democonfigsecret.multivalue.v2=dapr"); + Mockito.when(daprClient.getSecret(Mockito.eq("democonfig"), Mockito.eq("multivalue-properties"))).thenReturn(Mono.just(multiValueProperties)); + + Map singleValueProperties = new HashMap<>(); + singleValueProperties.put("dapr.spring.democonfigsecret.singlevalue", "testvalue"); + Mockito.when(daprClient.getSecret(Mockito.eq("democonfig"), Mockito.eq("dapr.spring.democonfigsecret.singlevalue"))).thenReturn(Mono.just(singleValueProperties)); + + Map> bulkProperties = new HashMap<>(); + bulkProperties.put("dapr.spring.democonfigsecret.singlevalue", singleValueProperties); + Mockito.when(daprClient.getBulkSecret(Mockito.eq("democonfig"))).thenReturn(Mono.just(bulkProperties)); + + Map singleValueConfigurationItems = new HashMap<>(); + singleValueConfigurationItems.put("dapr.spring.democonfigconfig.singlevalue", new ConfigurationItem("dapr.spring.democonfigconfig.singlevalue", "testvalue", "")); + Mockito.when(daprClient.getConfiguration(Mockito.refEq(new GetConfigurationRequest("democonfigconf", List.of("dapr.spring.democonfigconfig.singlevalue")), "metadata"))).thenReturn(Mono.just(singleValueConfigurationItems)); + + Map multiValueConfigurationItems = new HashMap<>(); + multiValueConfigurationItems.put("multivalue-yaml", new ConfigurationItem("multivalue-yaml", "dapr:\n spring:\n democonfigconfig:\n multivalue:\n v3: cloud", "")); + Mockito.when(daprClient.getConfiguration(Mockito.refEq(new GetConfigurationRequest("democonfigconf", List.of("multivalue-yaml")), "metadata"))).thenReturn(Mono.just(multiValueConfigurationItems)); + + ReflectionTestUtils.setField(DaprCloudConfigClientManager.class, "daprClient", + daprClient); + + } + catch (Exception ignore) { + ignore.printStackTrace(); + } + } + + @Autowired + MultipleConfig multipleConfig; + + @Autowired + SingleConfig singleConfig; + + @Test + public void testSecretStoreConfig() { + assertEquals("testvalue", singleConfig.getSingleValueSecret()); + + assertEquals("spring", multipleConfig.getMultipleSecretConfigV1()); + assertEquals("dapr", multipleConfig.getMultipleSecretConfigV2()); + } + + @Test + public void testConfigurationConfig() { + assertEquals("testvalue", singleConfig.getSingleValueConfig()); + + assertEquals("cloud", multipleConfig.getMultipleConfigurationConfigV3()); + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/config/MultipleConfig.java b/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/config/MultipleConfig.java new file mode 100644 index 0000000000..a3bf7328df --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/config/MultipleConfig.java @@ -0,0 +1,43 @@ +package io.dapr.spring.boot.cloudconfig.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class MultipleConfig { + + //should be spring + @Value("${dapr.spring.democonfigsecret.multivalue.v1}") + private String multipleSecretConfigV1; + //should be dapr + @Value("${dapr.spring.democonfigsecret.multivalue.v2}") + private String multipleSecretConfigV2; + + //should be cloud + @Value("${dapr.spring.democonfigconfig.multivalue.v3}") + private String multipleConfigurationConfigV3; + + public String getMultipleSecretConfigV1() { + return multipleSecretConfigV1; + } + + public void setMultipleSecretConfigV1(String multipleSecretConfigV1) { + this.multipleSecretConfigV1 = multipleSecretConfigV1; + } + + public String getMultipleSecretConfigV2() { + return multipleSecretConfigV2; + } + + public void setMultipleSecretConfigV2(String multipleSecretConfigV2) { + this.multipleSecretConfigV2 = multipleSecretConfigV2; + } + + public String getMultipleConfigurationConfigV3() { + return multipleConfigurationConfigV3; + } + + public void setMultipleConfigurationConfigV3(String multipleConfigurationConfigV3) { + this.multipleConfigurationConfigV3 = multipleConfigurationConfigV3; + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/config/SingleConfig.java b/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/config/SingleConfig.java new file mode 100644 index 0000000000..d18cc0d3a5 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/config/SingleConfig.java @@ -0,0 +1,32 @@ +package io.dapr.spring.boot.cloudconfig.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class SingleConfig { + + // should be testvalue + @Value("${dapr.spring.democonfigsecret.singlevalue}") + private String singleValueSecret; + + // should be testvalue + @Value("${dapr.spring.democonfigconfig.singlevalue}") + private String singleValueConfig; + + public String getSingleValueSecret() { + return singleValueSecret; + } + + public void setSingleValueSecret(String singleValueSecret) { + this.singleValueSecret = singleValueSecret; + } + + public String getSingleValueConfig() { + return singleValueConfig; + } + + public void setSingleValueConfig(String singleValueConfig) { + this.singleValueConfig = singleValueConfig; + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/test/resources/application.yaml b/dapr-spring/dapr-spring-cloudconfig/src/test/resources/application.yaml new file mode 100644 index 0000000000..890ef8a20c --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/test/resources/application.yaml @@ -0,0 +1,21 @@ +spring: + application: + name: producer-app + config: + import: + - dapr:secret:democonfig/multivalue-properties?type=doc + - dapr:secret:democonfig?type=doc + - dapr:secret:democonfig/dapr.spring.democonfigsecret.singlevalue?type=value + - dapr:config:democonfigconf/dapr.spring.democonfigconfig.singlevalue?type=value + - dapr:config:democonfigconf/multivalue-yaml?type=doc&doc-type=yaml + + +dapr: + cloudconfig: + enabled: true + # in case some connection issue + wait-sidecar-enabled: true + +logging: + level: + root: debug diff --git a/dapr-spring/pom.xml b/dapr-spring/pom.xml index fe4ebaa172..4dac964402 100644 --- a/dapr-spring/pom.xml +++ b/dapr-spring/pom.xml @@ -25,6 +25,7 @@ dapr-spring-boot-tests dapr-spring-boot-starters/dapr-spring-boot-starter dapr-spring-boot-starters/dapr-spring-boot-starter-test + dapr-spring-cloudconfig diff --git a/daprdocs/content/en/java-sdk-docs/spring-boot/_index.md b/daprdocs/content/en/java-sdk-docs/spring-boot/_index.md index 9819f8ef81..70dddccbdd 100644 --- a/daprdocs/content/en/java-sdk-docs/spring-boot/_index.md +++ b/daprdocs/content/en/java-sdk-docs/spring-boot/_index.md @@ -323,6 +323,146 @@ daprWorkflowClient.raiseEvent(instanceId, "MyEvenet", event); Check the [Dapr Workflow documentation](https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-overview/) for more information about how to work with Dapr Workflows. +## Using Dapr Cloud Config with Spring Boot + +To enable dapr cloud config, you should add following properties in your application's config (properties for example): + +```properties +# default enable is true, don't need to specify +dapr.cloudconfig.enabled = true +spring.config.import[0] = +spring.config.import[1] = +spring.config.import[2] = +#... keep going if you want to import more configs +``` + +There are other config of the dapr cloud config, listed below: + +```properties +#enable dapr cloud config or not (default = true). +dapr.cloudconfig.enabled=true +#timeout for getting dapr config (include wait for dapr sidecar) (default = 2000). +dapr.cloudconfig.timeout=2000 +#whether enable dapr client wait for sidecar, if no response, will throw IOException (default = false). +dapr.cloudconfig.wait-sidecar-enabled=false +#retries of dapr client wait for sidecar (default = 3). +dapr.cloudconfig.wait-sidecar-retries=3 +``` + +In Dapr Cloud Config component, we support two ways to import config: Secret Store API and Configuration API. + +Both of them have their schemas, listed below. + +### Cloud Config Import Schemas + +#### Secret Store Component + +##### url structure + +`dapr:secret:[/][?]` + +###### paramters + +| parameter | description | default | available | +|--------------------|--------------------|--------------------|--------------------| +| type | value type | `value` | `value`/`doc`| +| doc-type | type of doc | `properties` | `yaml`/`properties`/`or any file extensions you want`| + +- when type = `value`, if `secret-name` is specified, will treat secret as the value of property, and `secret-name` as the key of property; if none `secret-name` is specified, will get bulk secret and treat every value of secret as the value of property, and every key of secret as the key of property. +- when type = `doc`, if `secret-name` is specified, will treat secret as a bunch of property, and load it with property or yaml loader; if none `secret-name` is specified, will get bulk secret and and treat every value of secret as bunches of property, and load them with property or yaml loader. +- secret store with multiValud = true must specify nestedSeparator = ".", and using type = `doc` is not recommanded + +##### demo + +###### multiValued = false: + +####### store content(file secret store as example) + +```json +{ + "dapr.spring.demo-config-secret.singlevalue": "testvalue", + "multivalue-properties": "dapr.spring.demo-config-secret.multivalue.v1=spring\ndapr.spring.demo-config-secret.multivalue.v2=dapr", + "multivalue-yaml": "dapr:\n spring:\n demo-config-secret:\n multivalue:\n v3: cloud" +} +``` + +####### valid demo url + +- `dapr:secret:democonfig/multivalue-properties?type=doc&doc-type=properties` +- `dapr:secret:democonfig/multivalue-yaml?type=doc&doc-type=yaml` +- `dapr:secret:democonfig/dapr.spring.demo-config.singlevalue?type=value` +- `dapr:secret:democonfig?type=value` +- `dapr:secret:democonfig?type=doc` + +###### multiValued = true, nestedSeparator = ".": + +####### store content(file secret store as example) + +```json +{ + "value1": { + "dapr": { + "spring": { + "demo-config-secret": { + "multivalue": { + "v4": "config" + } + } + } + } + } +} +``` + +will be read as + +```json +{ + "value1": { + "dapr.spring.demo-config-secret.multivalue.v4": "config" + } +} +``` + +####### valid demo url +- `dapr:secret:demo-config-multi/value1?type=value` +- `dapr:secret:demo-config-multi?type=value` + +#### Configuration Component + +##### url structure + +`dapr:config:[/][?]` + +###### paramters + +| parameter | description | default | available | +|--------------------|--------------------|--------------------|--------------------| +| type | value type | `value` | `doc`/`value` | +| doc-type | type of doc | `properties` | `yaml`/`properties`/`or any file extensions you want`| +| subscribe | subscribe this configuration | `false` | `true`/`false` | + +- when subscribe = `true`, will subscribe update for the configuration. +- when type = `value`, if `key` is specified, will treat config value as the value of property, and `key` as the key of property; if none `key` is specified, will get all key and value in the `config-name` and treat every config value as the value of property, and every `key` as the key of property. +- when type = `doc`, if `key` is specified, will treat config value as a bunch of property, and load it with property or yaml loader; if none `key` is specified, will get all key and value in the `config-name` and treat every config value as bunches of property, and load them with property or yaml loader. + +##### Demo + +###### store content(table as example) + +| key | value | +|--------------------|--------------------| +| dapr.spring.demo-config-config.singlevalue | testvalue | +| multivalue-properties | `dapr.spring.demo-config-config.multivalue.v1=spring\ndapr.spring.demo-config-config.multivalue.v2=dapr` | +| multivalue-yaml | `dapr:\n spring:\n demo-config-config:\n multivalue:\n v3: cloud` | + +###### valid demo url + +- `dapr:config:democonfigconf/dapr.spring.demo-config-config.singlevalue?type=value` +- `dapr:config:democonfigconf/multivalue-properties?type=doc&doc-type=properties` +- `dapr:config:democonfigconf/multivalue-yaml?type=doc&doc-type=yaml` +- `dapr:config:democonfigconf?type=doc` +- `dapr:config:democonfigconf?type=value` ## Next steps diff --git a/sdk-tests/components/secret-spring/multivalued.json b/sdk-tests/components/secret-spring/multivalued.json new file mode 100644 index 0000000000..8cfa56e9ac --- /dev/null +++ b/sdk-tests/components/secret-spring/multivalued.json @@ -0,0 +1,13 @@ +{ + "value1": { + "dapr": { + "spring": { + "demo-config-secret": { + "multivalue": { + "v4": "config" + } + } + } + } + } +} \ No newline at end of file diff --git a/sdk-tests/components/secret-spring/singlevalued.json b/sdk-tests/components/secret-spring/singlevalued.json new file mode 100644 index 0000000000..6e9fd591e5 --- /dev/null +++ b/sdk-tests/components/secret-spring/singlevalued.json @@ -0,0 +1,5 @@ +{ + "dapr.spring.demo-config-secret.singlevalue": "testvalue", + "multivalue-properties": "dapr.spring.demo-config-secret.multivalue.v1=spring\ndapr.spring.demo-config-secret.multivalue.v2=dapr", + "multivalue-yaml": "dapr:\n spring:\n demo-config-secret:\n multivalue:\n v3: cloud" +} \ No newline at end of file diff --git a/sdk-tests/components/secretstore-springboot-multivalued.yaml b/sdk-tests/components/secretstore-springboot-multivalued.yaml new file mode 100644 index 0000000000..24ac13d589 --- /dev/null +++ b/sdk-tests/components/secretstore-springboot-multivalued.yaml @@ -0,0 +1,14 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: democonfigMultivalued +spec: + type: secretstores.local.file + version: v1 + metadata: + - name: secretsFile + value: "./components/secret-spring/multivalued.json" + - name: nestedSeparator + value: "." + - name: multiValued + value: "true" diff --git a/sdk-tests/components/secretstore-springboot-singlevalued.yaml b/sdk-tests/components/secretstore-springboot-singlevalued.yaml new file mode 100644 index 0000000000..17a8580f54 --- /dev/null +++ b/sdk-tests/components/secretstore-springboot-singlevalued.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: democonfig +spec: + type: secretstores.local.file + version: v1 + metadata: + - name: secretsFile + value: "./components/secret-spring/singlevalued.json" + - name: multiValued + value: "false" diff --git a/sdk-tests/pom.xml b/sdk-tests/pom.xml index c1ffacad0e..50df90e7ba 100644 --- a/sdk-tests/pom.xml +++ b/sdk-tests/pom.xml @@ -69,6 +69,12 @@ commons-cli 1.9.0 + + redis.clients + jedis + 5.2.0 + test + io.grpc grpc-protobuf @@ -228,6 +234,12 @@ ${testcontainers-test.version} test + + com.redis + testcontainers-redis + 2.2.4 + test + jakarta.annotation jakarta.annotation-api diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprCloudConfigIT.java b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprCloudConfigIT.java new file mode 100644 index 0000000000..a8cb4662a2 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprCloudConfigIT.java @@ -0,0 +1,106 @@ +package io.dapr.it.spring.cloudconfig; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.redis.testcontainers.RedisContainer; +import io.dapr.testcontainers.Component; +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.DaprLogLevel; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.testcontainers.containers.Network; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import redis.clients.jedis.Jedis; + +import java.util.List; +import java.util.Map; + +import static io.dapr.it.testcontainers.DaprContainerConstants.IMAGE_TAG; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(properties = { + "spring.config.import[0]=dapr:config:" + DaprCloudConfigIT.CONFIG_STORE_NAME + + "/" + DaprCloudConfigIT.CONFIG_MULTI_NAME + "?type=doc&doc-type=yaml", + "spring.config.import[1]=dapr:config:" + DaprCloudConfigIT.CONFIG_STORE_NAME + + "/" + DaprCloudConfigIT.CONFIG_SINGLE_NAME + "?type=value", + "dapr.cloudconfig.wait-sidecar-enabled=true", + "dapr.cloudconfig.wait-sidecar-retries=5", +}) +@ContextConfiguration(classes = TestDaprCloudConfigConfiguration.class) +@ExtendWith(SpringExtension.class) +@Testcontainers +@Tag("testcontainers") +public class DaprCloudConfigIT { + public static final String CONFIG_STORE_NAME = "democonfigconf"; + public static final String CONFIG_MULTI_NAME = "multivalue-yaml"; + public static final String CONFIG_SINGLE_NAME = "dapr.spring.demo-config-config.singlevalue"; + + private static final Map STORE_PROPERTY = generateStoreProperty(); + + private static final Network DAPR_NETWORK = Network.newNetwork(); + + private static final RedisContainer REDIS_CONTAINER = new RedisContainer( + RedisContainer.DEFAULT_IMAGE_NAME.withTag(RedisContainer.DEFAULT_TAG)) { + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + super.containerIsStarted(containerInfo); + + String address = getHost(); + Integer port = getMappedPort(6379); + + Logger logger = LoggerFactory.getLogger(DaprCloudConfigIT.class); + // Put values using Jedis + try (Jedis jedis = new Jedis(address, port)) { + logger.info("Putting Dapr Cloud config to {}:{}", address, port); + jedis.set(DaprCloudConfigIT.CONFIG_MULTI_NAME, DaprConfigurationStores.YAML_CONFIG); + jedis.set(DaprCloudConfigIT.CONFIG_SINGLE_NAME, "testvalue"); + } + } + } + .withNetworkAliases("redis") + .withCommand() + .withNetwork(DAPR_NETWORK); + + @Container + @ServiceConnection + private static final DaprContainer DAPR_CONTAINER = new DaprContainer(IMAGE_TAG) + .withAppName("configuration-dapr-app") + .withNetwork(DAPR_NETWORK) + .withComponent(new Component(CONFIG_STORE_NAME, "configuration.redis", "v1", STORE_PROPERTY)) + .withDaprLogLevel(DaprLogLevel.DEBUG) + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .dependsOn(REDIS_CONTAINER); + + static { + DAPR_CONTAINER.setPortBindings(List.of("3500:3500", "50001:50001")); + } + + private static Map generateStoreProperty() { + return Map.of("redisHost", "redis:6379", + "redisPassword", ""); + } + + @Value("${dapr.spring.demo-config-config.singlevalue}") + String valueConfig; + + @Value("${dapr.spring.demo-config-config.multivalue.v3}") + String yamlConfig; + + @Test + public void configTest() { + assertEquals("testvalue", valueConfig); + assertEquals("cloud", yamlConfig); + } + +} diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprConfigurationStores.java b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprConfigurationStores.java new file mode 100644 index 0000000000..9c25dc4761 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprConfigurationStores.java @@ -0,0 +1,9 @@ +package io.dapr.it.spring.cloudconfig; + +public class DaprConfigurationStores { + public static final String YAML_CONFIG = "dapr:\n" + + " spring:\n" + + " demo-config-config:\n" + + " multivalue:\n" + + " v3: cloud"; +} diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprSecretStoreIT.java b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprSecretStoreIT.java new file mode 100644 index 0000000000..d3930022a7 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprSecretStoreIT.java @@ -0,0 +1,103 @@ +package io.dapr.it.spring.cloudconfig; + +import io.dapr.testcontainers.Component; +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.DaprLogLevel; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.testcontainers.containers.Network; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; +import java.util.Map; + +import static io.dapr.it.testcontainers.DaprContainerConstants.IMAGE_TAG; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(properties = { + "spring.config.import[0]=dapr:secret:" + DaprSecretStoreIT.SECRET_STORE_NAME + + "/" + DaprSecretStoreIT.SECRET_MULTI_NAME + "?type=doc", + "spring.config.import[1]=dapr:secret:" + DaprSecretStoreIT.SECRET_STORE_NAME + + "/" + DaprSecretStoreIT.SECRET_SINGLE_NAME + "?type=value", + "spring.config.import[2]=dapr:secret:" + DaprSecretStoreIT.SECRET_STORE_NAME_MULTI + + "?type=value", + "dapr.cloudconfig.wait-sidecar-enabled=true", + "dapr.cloudconfig.wait-sidecar-retries=5", +}) +@ContextConfiguration(classes = TestDaprCloudConfigConfiguration.class) +@ExtendWith(SpringExtension.class) +@Testcontainers +@Tag("testcontainers") +public class DaprSecretStoreIT { + public static final String SECRET_STORE_NAME = "democonfig"; + public static final String SECRET_MULTI_NAME = "multivalue-properties"; + public static final String SECRET_SINGLE_NAME = "dapr.spring.demo-config-secret.singlevalue"; + + public static final String SECRET_STORE_NAME_MULTI = "democonfigMultivalued"; + + private static final Map SINGLE_VALUE_PROPERTY = generateSingleValueProperty(); + private static final Map MULTI_VALUE_PROPERTY = generateMultiValueProperty(); + + private static final Network DAPR_NETWORK = Network.newNetwork(); + + @Container + @ServiceConnection + private static final DaprContainer DAPR_CONTAINER = new DaprContainer(IMAGE_TAG) + .withAppName("secret-store-dapr-app") + .withComponent(new Component(SECRET_STORE_NAME, "secretstores.local.file", "v1", SINGLE_VALUE_PROPERTY)) + .withComponent(new Component(SECRET_STORE_NAME_MULTI, "secretstores.local.file", "v1", MULTI_VALUE_PROPERTY)) + .withNetwork(DAPR_NETWORK) + .withDaprLogLevel(DaprLogLevel.DEBUG) + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .withCopyToContainer(Transferable.of(DaprSecretStores.SINGLE_VALUED_SECRET), "/dapr-secrets/singlevalued.json") + .withCopyToContainer(Transferable.of(DaprSecretStores.MULTI_VALUED_SECRET), "/dapr-secrets/multivalued.json"); + + static { + DAPR_CONTAINER.setPortBindings(List.of("3500:3500", "50001:50001")); + } + + private static Map generateSingleValueProperty() { + return Map.of("secretsFile", "/dapr-secrets/singlevalued.json", + "multiValued", "false"); + } + + private static Map generateMultiValueProperty() { + return Map.of("secretsFile", "/dapr-secrets/multivalued.json", + "nestedSeparator", ".", + "multiValued", "true"); + } + + @Value("${dapr.spring.demo-config-secret.singlevalue}") + String singleValue; + + @Value("${dapr.spring.demo-config-secret.multivalue.v1}") + String multiV1; + + @Value("${dapr.spring.demo-config-secret.multivalue.v2}") + String multiV2; + + @Value("${dapr.spring.demo-config-secret.multivalue.v4}") + String multiV4; + + @Test + public void testSecretStore() { + assertEquals("testvalue", singleValue); + assertEquals("spring", multiV1); + assertEquals("dapr", multiV2); + assertEquals("config", multiV4); + } + +} diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprSecretStores.java b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprSecretStores.java new file mode 100644 index 0000000000..67601b1baf --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprSecretStores.java @@ -0,0 +1,23 @@ +package io.dapr.it.spring.cloudconfig; + +public class DaprSecretStores { + public static final String SINGLE_VALUED_SECRET = "{\n" + + " \"dapr.spring.demo-config-secret.singlevalue\": \"testvalue\",\n" + + " \"multivalue-properties\": \"dapr.spring.demo-config-secret.multivalue.v1=spring\\ndapr.spring.demo-config-secret.multivalue.v2=dapr\",\n" + + " \"multivalue-yaml\": \"dapr:\\n spring:\\n demo-config-secret:\\n multivalue:\\n v3: cloud\"\n" + + "}"; + + public static final String MULTI_VALUED_SECRET = "{\n" + + " \"value1\": {\n" + + " \"dapr\": {\n" + + " \"spring\": {\n" + + " \"demo-config-secret\": {\n" + + " \"multivalue\": {\n" + + " \"v4\": \"config\"\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + "}"; +} diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/TestDaprCloudConfigConfiguration.java b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/TestDaprCloudConfigConfiguration.java new file mode 100644 index 0000000000..62fb063752 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/TestDaprCloudConfigConfiguration.java @@ -0,0 +1,11 @@ +package io.dapr.it.spring.cloudconfig; + +import io.dapr.spring.boot.autoconfigure.client.DaprClientAutoConfiguration; +import io.dapr.spring.boot.cloudconfig.autoconfigure.DaprCloudConfigAutoConfiguration; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +@Configuration +@Import({DaprClientAutoConfiguration.class, DaprCloudConfigAutoConfiguration.class}) +public class TestDaprCloudConfigConfiguration { +} diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index f0111ca638..35f66c7d95 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -51,4 +51,10 @@ + + + + + + \ No newline at end of file diff --git a/spring-boot-examples/README.md b/spring-boot-examples/README.md index 3cc88610de..1696b8bc60 100644 --- a/spring-boot-examples/README.md +++ b/spring-boot-examples/README.md @@ -1,12 +1,14 @@ # Dapr Spring Boot and Testcontainers integration Example -This example consists of two applications: +This example consists of three applications: - Producer App: - Publish messages using a Spring Messaging approach - Store and retrieve information using Spring Data CrudRepository - Implements a Workflow with Dapr Workflows - Consumer App: - Subscribe to messages +- Cloud Config Demo: + - Import and use configs ## Running these examples from source code @@ -27,7 +29,7 @@ expected_stdout_lines: background: true expected_return_code: 143 sleep: 30 -timeout_seconds: 45 +timeout_seconds: 75 --> @@ -68,6 +70,31 @@ cd consumer-app/ The `consumer-app` starts in port `8081` by default. +To run `cloud-config-demo`, you should run in a terminal (`cloud-config-demo` doesn't depends on two applications above): + + + +```sh +cd cloud-config-demo/ +../../mvnw -Dspring-boot.run.arguments="--reuse=true" spring-boot:test-run +``` + + + +The `cloud-config-demo` starts in port `8082` by default. + +It will work and gain secrets from secret store. you can also uncomment the lines in application.yaml to enable more configuration imports. + ## Interacting with the applications Now that both applications are up you can place an order by sending a POST request to `:8080/orders/` @@ -171,6 +198,28 @@ Customer: salaboy follow-up done. Congratulations the customer: salaboy is happy! ``` +You can check the config in CloudConfig app, just run: + + + + +```sh +curl -X GET localhost:8082/config +``` + + + +You will get `testvalue` in terminal stdout. + ## Running on Kubernetes You can run the same example on a Kubernetes cluster. [Check the Kubernetes tutorial here](kubernetes/README.md). diff --git a/spring-boot-examples/cloud-config-demo/components/redisconfigstore.yaml b/spring-boot-examples/cloud-config-demo/components/redisconfigstore.yaml new file mode 100644 index 0000000000..6b1f3f799f --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/components/redisconfigstore.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: democonfigconf +spec: + type: configuration.redis + version: v1 + metadata: + - name: redisHost + value: localhost:6379 + - name: redisPassword + value: "" diff --git a/spring-boot-examples/cloud-config-demo/components/secret-spring/multivalued.json b/spring-boot-examples/cloud-config-demo/components/secret-spring/multivalued.json new file mode 100644 index 0000000000..8cfa56e9ac --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/components/secret-spring/multivalued.json @@ -0,0 +1,13 @@ +{ + "value1": { + "dapr": { + "spring": { + "demo-config-secret": { + "multivalue": { + "v4": "config" + } + } + } + } + } +} \ No newline at end of file diff --git a/spring-boot-examples/cloud-config-demo/components/secret-spring/singlevalued.json b/spring-boot-examples/cloud-config-demo/components/secret-spring/singlevalued.json new file mode 100644 index 0000000000..6e9fd591e5 --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/components/secret-spring/singlevalued.json @@ -0,0 +1,5 @@ +{ + "dapr.spring.demo-config-secret.singlevalue": "testvalue", + "multivalue-properties": "dapr.spring.demo-config-secret.multivalue.v1=spring\ndapr.spring.demo-config-secret.multivalue.v2=dapr", + "multivalue-yaml": "dapr:\n spring:\n demo-config-secret:\n multivalue:\n v3: cloud" +} \ No newline at end of file diff --git a/spring-boot-examples/cloud-config-demo/components/secretstore-springboot-multivalued.yaml b/spring-boot-examples/cloud-config-demo/components/secretstore-springboot-multivalued.yaml new file mode 100644 index 0000000000..24ac13d589 --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/components/secretstore-springboot-multivalued.yaml @@ -0,0 +1,14 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: democonfigMultivalued +spec: + type: secretstores.local.file + version: v1 + metadata: + - name: secretsFile + value: "./components/secret-spring/multivalued.json" + - name: nestedSeparator + value: "." + - name: multiValued + value: "true" diff --git a/spring-boot-examples/cloud-config-demo/components/secretstore-springboot-singlevalued.yaml b/spring-boot-examples/cloud-config-demo/components/secretstore-springboot-singlevalued.yaml new file mode 100644 index 0000000000..17a8580f54 --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/components/secretstore-springboot-singlevalued.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: democonfig +spec: + type: secretstores.local.file + version: v1 + metadata: + - name: secretsFile + value: "./components/secret-spring/singlevalued.json" + - name: multiValued + value: "false" diff --git a/spring-boot-examples/cloud-config-demo/pom.xml b/spring-boot-examples/cloud-config-demo/pom.xml new file mode 100644 index 0000000000..65f58fa31a --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/pom.xml @@ -0,0 +1,90 @@ + + + 4.0.0 + + + io.dapr + spring-boot-examples + 0.15.0-SNAPSHOT + + + cloud-config-demo + cloud-config-demo + Spring Boot, Testcontainers and Dapr Integration Examples :: Cloud Config Demo + + + + + + org.springframework.boot + spring-boot-dependencies + ${springboot.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-web + + + io.dapr.spring + dapr-spring-boot-starter + ${dapr.sdk.alpha.version} + + + io.dapr.spring + dapr-spring-boot-starter-test + ${dapr.sdk.alpha.version} + test + + + org.testcontainers + junit-jupiter + test + + + redis.clients + jedis + 5.2.0 + test + + + com.redis + testcontainers-redis + 2.2.4 + test + + + io.rest-assured + rest-assured + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-site-plugin + 3.12.1 + + true + + + + + diff --git a/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/CloudConfigApplication.java b/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/CloudConfigApplication.java new file mode 100644 index 0000000000..d3fa3f2de2 --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/CloudConfigApplication.java @@ -0,0 +1,27 @@ +/* + * Copyright 2025 The Dapr 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 + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.cloudconfig; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +@SpringBootApplication +public class CloudConfigApplication { + + public static void main(String[] args) { + SpringApplication.run(CloudConfigApplication.class, args); + } + +} diff --git a/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/DemoController.java b/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/DemoController.java new file mode 100644 index 0000000000..68d8a12945 --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/DemoController.java @@ -0,0 +1,21 @@ +package io.dapr.springboot.examples.cloudconfig; + +import io.dapr.springboot.examples.cloudconfig.config.SingleConfig; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class DemoController { + + private final SingleConfig singleConfig; + + public DemoController(SingleConfig singleConfig) { + this.singleConfig = singleConfig; + } + + @GetMapping("/config") + public String getConfig() { + return singleConfig.getSingleValueSecret(); + } + +} diff --git a/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/config/MultipleConfig.java b/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/config/MultipleConfig.java new file mode 100644 index 0000000000..442ebb9d56 --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/config/MultipleConfig.java @@ -0,0 +1,32 @@ +package io.dapr.springboot.examples.cloudconfig.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class MultipleConfig { + + //should be spring + @Value("${dapr.spring.demo-config-secret.multivalue.v1}") + private String multipleSecretConfigV1; + //should be dapr + @Value("${dapr.spring.demo-config-secret.multivalue.v2}") + private String multipleSecretConfigV2; + + + public String getMultipleSecretConfigV1() { + return multipleSecretConfigV1; + } + + public void setMultipleSecretConfigV1(String multipleSecretConfigV1) { + this.multipleSecretConfigV1 = multipleSecretConfigV1; + } + + public String getMultipleSecretConfigV2() { + return multipleSecretConfigV2; + } + + public void setMultipleSecretConfigV2(String multipleSecretConfigV2) { + this.multipleSecretConfigV2 = multipleSecretConfigV2; + } +} diff --git a/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/config/SingleConfig.java b/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/config/SingleConfig.java new file mode 100644 index 0000000000..e3cfc68d3f --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/config/SingleConfig.java @@ -0,0 +1,20 @@ +package io.dapr.springboot.examples.cloudconfig.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class SingleConfig { + + // should be testvalue + @Value("${dapr.spring.demo-config-secret.singlevalue}") + private String singleValueSecret; + + public String getSingleValueSecret() { + return singleValueSecret; + } + + public void setSingleValueSecret(String singleValueSecret) { + this.singleValueSecret = singleValueSecret; + } +} diff --git a/spring-boot-examples/cloud-config-demo/src/main/resources/application.yaml b/spring-boot-examples/cloud-config-demo/src/main/resources/application.yaml new file mode 100644 index 0000000000..a6a734c3dc --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/main/resources/application.yaml @@ -0,0 +1,22 @@ +spring: + application: + name: cloud-config-demo + config: + import: + - dapr:secret:democonfig/multivalue-properties?type=doc + - dapr:secret:democonfig/dapr.spring.demo-config-secret.singlevalue?type=value +# Following line works by default even if no secret store components defined in dapr running +# inside kubernetes cluster +# To maintain the compatibility of local version, we defined a secret store to dapr kubernetes, +# so following lines are commented. +# - dapr:secret:kubernetes/multivalue-properties?type=doc +# - dapr:secret:kubernetes/dapr.spring.demo-config-secret.singlevalue?type=value + +# Following line includes dapr configuration schema of Cloud Config +# Unfortunately current dapr runtime doesn't support Kubernetes ConfigMap for configuration, +# so to maintain the compatibility of Kubernetes, following lines are commented. +# - dapr:config:democonfigconf/dapr.spring.demo-config-config.singlevalue?type=value +# - dapr:config:democonfigconf/multivalue-yaml?type=doc&doc-type=yaml +server: + port: 8082 + diff --git a/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/CloudConfigTests.java b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/CloudConfigTests.java new file mode 100644 index 0000000000..e0fd862d1e --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/CloudConfigTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2025 The Dapr 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 + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.cloudconfig; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.redis.testcontainers.RedisContainer; +import io.dapr.springboot.examples.cloudconfig.config.MultipleConfig; +import io.dapr.springboot.examples.cloudconfig.config.SingleConfig; +import io.dapr.testcontainers.Component; +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.DaprLogLevel; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import redis.clients.jedis.Jedis; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.CoreMatchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(classes = {TestCloudConfigApplication.class, DaprTestContainersConfig.class,}, + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +@Testcontainers +@Tag("testcontainers") +class CloudConfigTests { + + static { + DaprTestContainersConfig.DAPR_CONTAINER.start(); + } + + @BeforeEach + void setUp() { + RestAssured.baseURI = "http://localhost:" + 8082; + org.testcontainers.Testcontainers.exposeHostPorts(8082); + } + + @Autowired + MultipleConfig multipleConfig; + + @Autowired + SingleConfig singleConfig; + + @Test + void testCloudConfig() { + assertEquals("testvalue", singleConfig.getSingleValueSecret()); + assertEquals("spring", multipleConfig.getMultipleSecretConfigV1()); + assertEquals("dapr", multipleConfig.getMultipleSecretConfigV2()); + } + + @Test + void testController() { + given().contentType(ContentType.JSON) + .when() + .get("/config") + .then() + .statusCode(200).body(is("testvalue")); + } + + @Test + void fillCoverage() { + new SingleConfig().setSingleValueSecret("testvalue"); + new MultipleConfig().setMultipleSecretConfigV1("spring"); + new MultipleConfig().setMultipleSecretConfigV2("dapr"); + } + +} diff --git a/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprConfigurationStores.java b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprConfigurationStores.java new file mode 100644 index 0000000000..87911de3e1 --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprConfigurationStores.java @@ -0,0 +1,9 @@ +package io.dapr.springboot.examples.cloudconfig; + +public class DaprConfigurationStores { + public static final String YAML_CONFIG = "dapr:\n" + + " spring:\n" + + " demo-config-config:\n" + + " multivalue:\n" + + " v3: cloud"; +} diff --git a/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprSecretStores.java b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprSecretStores.java new file mode 100644 index 0000000000..ef05862cb1 --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprSecretStores.java @@ -0,0 +1,23 @@ +package io.dapr.springboot.examples.cloudconfig; + +public class DaprSecretStores { + public static final String SINGLE_VALUED_SECRET = "{\n" + + " \"dapr.spring.demo-config-secret.singlevalue\": \"testvalue\",\n" + + " \"multivalue-properties\": \"dapr.spring.demo-config-secret.multivalue.v1=spring\\ndapr.spring.demo-config-secret.multivalue.v2=dapr\",\n" + + " \"multivalue-yaml\": \"dapr:\\n spring:\\n demo-config-secret:\\n multivalue:\\n v3: cloud\"\n" + + "}"; + + public static final String MULTI_VALUED_SECRET = "{\n" + + " \"value1\": {\n" + + " \"dapr\": {\n" + + " \"spring\": {\n" + + " \"demo-config-secret\": {\n" + + " \"multivalue\": {\n" + + " \"v4\": \"config\"\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + "}"; +} diff --git a/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprTestContainersConfig.java b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprTestContainersConfig.java new file mode 100644 index 0000000000..67ced02c56 --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprTestContainersConfig.java @@ -0,0 +1,115 @@ +/* + * Copyright 2025 The Dapr 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 + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.cloudconfig; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.redis.testcontainers.RedisContainer; +import io.dapr.testcontainers.Component; +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.DaprLogLevel; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.Network; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.junit.jupiter.Container; +import redis.clients.jedis.Jedis; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@TestConfiguration(proxyBeanMethods = false) +public class DaprTestContainersConfig { + public static final String CONFIG_STORE_NAME = "democonfigconf"; + public static final String CONFIG_MULTI_NAME = "multivalue-yaml"; + public static final String CONFIG_SINGLE_NAME = "dapr.spring.demo-config-config.singlevalue"; + + public static final String SECRET_STORE_NAME = "democonfig"; + public static final String SECRET_MULTI_NAME = "multivalue-properties"; + public static final String SECRET_SINGLE_NAME = "dapr.spring.demo-config-secret.singlevalue"; + + public static final String SECRET_STORE_NAME_MULTI = "democonfigMultivalued"; + + private static final Map SINGLE_VALUE_PROPERTY = generateSingleValueProperty(); + private static final Map MULTI_VALUE_PROPERTY = generateMultiValueProperty(); + + + private static final Map STORE_PROPERTY = generateStoreProperty(); + + private static final Network DAPR_NETWORK = Network.newNetwork(); + + private static Map generateStoreProperty() { + return Map.of("redisHost", "redis:6379", + "redisPassword", ""); + } + + private static Map generateSingleValueProperty() { + return Map.of("secretsFile", "/dapr-secrets/singlevalued.json", + "multiValued", "false"); + } + + private static Map generateMultiValueProperty() { + return Map.of("secretsFile", "/dapr-secrets/multivalued.json", + "nestedSeparator", ".", + "multiValued", "true"); + } + + @Container + private static final RedisContainer REDIS_CONTAINER = new RedisContainer( + RedisContainer.DEFAULT_IMAGE_NAME.withTag(RedisContainer.DEFAULT_TAG)) { + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + super.containerIsStarted(containerInfo); + + String address = getHost(); + Integer port = getMappedPort(6379); + + Logger logger = LoggerFactory.getLogger(CloudConfigTests.class); + // Put values using Jedis + try (Jedis jedis = new Jedis(address, port)) { + logger.info("Putting Dapr Cloud config to {}:{}", address, port); + jedis.set(DaprTestContainersConfig.CONFIG_MULTI_NAME, DaprConfigurationStores.YAML_CONFIG); + jedis.set(DaprTestContainersConfig.CONFIG_SINGLE_NAME, "testvalue"); + } + } + } + .withNetworkAliases("redis") + .withCommand() + .withNetwork(DAPR_NETWORK); + + @Container + @ServiceConnection + public static final DaprContainer DAPR_CONTAINER = new DaprContainer("daprio/daprd:1.14.4") + .withAppName("configuration-dapr-app") + .withNetwork(DAPR_NETWORK) + .withComponent(new Component(CONFIG_STORE_NAME, "configuration.redis", "v1", STORE_PROPERTY)) + .withComponent(new Component(SECRET_STORE_NAME, "secretstores.local.file", "v1", SINGLE_VALUE_PROPERTY)) + .withComponent(new Component(SECRET_STORE_NAME_MULTI, "secretstores.local.file", "v1", MULTI_VALUE_PROPERTY)) + .withDaprLogLevel(DaprLogLevel.DEBUG) + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .dependsOn(REDIS_CONTAINER) + .withCopyToContainer(Transferable.of(DaprSecretStores.SINGLE_VALUED_SECRET), "/dapr-secrets/singlevalued.json") + .withCopyToContainer(Transferable.of(DaprSecretStores.MULTI_VALUED_SECRET), "/dapr-secrets/multivalued.json"); + + static { + DAPR_CONTAINER.setPortBindings(List.of("3500:3500", "50001:50001")); + } +} diff --git a/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/TestCloudConfigApplication.java b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/TestCloudConfigApplication.java new file mode 100644 index 0000000000..5871e6e633 --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/TestCloudConfigApplication.java @@ -0,0 +1,35 @@ +/* + * Copyright 2025 The Dapr 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 + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.cloudconfig; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +@SpringBootApplication +public class TestCloudConfigApplication { + + static { + DaprTestContainersConfig.DAPR_CONTAINER.start(); + } + + public static void main(String[] args) { + + SpringApplication.from(CloudConfigApplication::main) + .with(DaprTestContainersConfig.class) + .run(args); + org.testcontainers.Testcontainers.exposeHostPorts(8082); + } + +} diff --git a/spring-boot-examples/kubernetes/README.md b/spring-boot-examples/kubernetes/README.md index 3bb5da421e..750c83c871 100644 --- a/spring-boot-examples/kubernetes/README.md +++ b/spring-boot-examples/kubernetes/README.md @@ -1,7 +1,11 @@ # Running this example on Kubernetes To run this example on Kubernetes, you can use any Kubernetes distribution. -We install Dapr on a Kubernetes cluster and then we will deploy both the `producer-app` and `consumer-app`. +We install Dapr on a Kubernetes cluster and then we will deploy the `producer-app`, `consumer-app` and `cloud-config-demo`. + +> ___A Note for `cloud-config-demo`:___ +> +> Remind that only Kubernetes Secret works on dapr secret, Kubernetes ConfigMap doesn't support in dapr runtime, and there is no easy way to fill data to redis running in cluster before container started automatically, so configuration schema are commented by default. ## Creating a cluster and installing Dapr @@ -35,6 +39,32 @@ helm upgrade --install dapr dapr/dapr \ --create-namespace \ --wait ``` +__Optional: pre-configure data needed by `cloud-config-demo`__ + +If you want to run `cloud-config-demo` on your cluster, you should apply the secrets file from `secrets/kubernates-secrets-multivalue.yaml`, or you can just run following command: + +```bash +cat < @@ -117,5 +167,9 @@ and ```bash kubectl logs -f consumer-app- ``` +and +```bash +kubectl logs -f cloud-config-demo- +``` diff --git a/spring-boot-examples/kubernetes/cloud-config-demo.yaml b/spring-boot-examples/kubernetes/cloud-config-demo.yaml new file mode 100644 index 0000000000..04c80b5aae --- /dev/null +++ b/spring-boot-examples/kubernetes/cloud-config-demo.yaml @@ -0,0 +1,45 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: cloud-config-demo + name: cloud-config-demo +spec: + type: NodePort + ports: + - name: "cloud-config-demo" + port: 8082 + targetPort: 8082 + nodePort: 31002 + selector: + app: cloud-config-demo + +--- + +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: cloud-config-demo + name: cloud-config-demo +spec: + replicas: 1 + selector: + matchLabels: + app: cloud-config-demo + template: + metadata: + annotations: + dapr.io/app-id: cloud-config-demo + dapr.io/app-port: "8082" + dapr.io/enabled: "true" + labels: + app: cloud-config-demo + spec: + containers: + - image: localhost:5001/sb-cloud-config-demo + name: cloud-config-demo + imagePullPolicy: Always + ports: + - containerPort: 8082 + name: cloud-config diff --git a/spring-boot-examples/kubernetes/secrets/kubernates-secrets-multivalue.yaml b/spring-boot-examples/kubernetes/secrets/kubernates-secrets-multivalue.yaml new file mode 100644 index 0000000000..74c1598a3c --- /dev/null +++ b/spring-boot-examples/kubernetes/secrets/kubernates-secrets-multivalue.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Secret +metadata: + name: multivalue-properties +type: Opaque +stringData: + value1: | + dapr.spring.demo-config-secret.multivalue.v1=spring + dapr.spring.demo-config-secret.multivalue.v2=dapr + value2: "dapr.spring.demo-config-secret.multivalue.v3=cloud" +--- +apiVersion: v1 +kind: Secret +metadata: + name: dapr.spring.demo-config-secret.singlevalue +type: Opaque +stringData: + dapr.spring.demo-config-secret.singlevalue: "testvalue" \ No newline at end of file diff --git a/spring-boot-examples/kubernetes/secretstore.yaml b/spring-boot-examples/kubernetes/secretstore.yaml new file mode 100644 index 0000000000..4c84e1f8e4 --- /dev/null +++ b/spring-boot-examples/kubernetes/secretstore.yaml @@ -0,0 +1,8 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: democonfig +spec: + type: secretstores.kubernetes + version: v1 + metadata: [] diff --git a/spring-boot-examples/pom.xml b/spring-boot-examples/pom.xml index 75a32364f7..6667ec0cab 100644 --- a/spring-boot-examples/pom.xml +++ b/spring-boot-examples/pom.xml @@ -21,6 +21,7 @@ producer-app consumer-app + cloud-config-demo