From bfa664b73ec1d5aa40d189d837301a133070e169 Mon Sep 17 00:00:00 2001 From: chenzhenyang Date: Mon, 8 Sep 2025 11:39:32 +0800 Subject: [PATCH] implement apollo dynamic config Signed-off-by: chenzhenyang --- pom.xml | 1 + .../common/DynamicConfigServiceType.java | 5 + .../sermant-agentcore-implement/pom.xml | 59 +++ .../BufferedDynamicConfigService.java | 4 + .../apollo/ApolloBufferedClient.java | 126 +++++ .../dynamicconfig/apollo/ApolloClient.java | 335 +++++++++++++ .../apollo/ApolloDynamicConfigService.java | 294 +++++++++++ .../dynamicconfig/apollo/ApolloUtils.java | 73 +++ .../config/ApolloApplicationProvider.java | 72 +++ .../config/ApolloMetaServerProvider.java | 47 ++ .../apollo/config/ApolloProperty.java | 117 +++++ .../apollo/config/ApolloProviderManager.java | 45 ++ .../apollo/config/ApolloServerProvider.java | 60 +++ ...amework.apollo.core.spi.MetaServerProvider | 1 + ...p.framework.foundation.spi.ProviderManager | 1 + .../ApolloDynamicConfigServiceTest.java | 456 ++++++++++++++++++ .../backend/service/ConfigService.java | 10 + 17 files changed, 1706 insertions(+) create mode 100644 sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloBufferedClient.java create mode 100644 sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloClient.java create mode 100644 sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloDynamicConfigService.java create mode 100644 sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloUtils.java create mode 100644 sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloApplicationProvider.java create mode 100644 sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloMetaServerProvider.java create mode 100644 sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloProperty.java create mode 100644 sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloProviderManager.java create mode 100644 sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloServerProvider.java create mode 100644 sermant-agentcore/sermant-agentcore-implement/src/main/resources/META-INF/services/com.ctrip.framework.apollo.core.spi.MetaServerProvider create mode 100644 sermant-agentcore/sermant-agentcore-implement/src/main/resources/META-INF/services/com.ctrip.framework.foundation.spi.ProviderManager create mode 100644 sermant-agentcore/sermant-agentcore-implement/src/test/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloDynamicConfigServiceTest.java diff --git a/pom.xml b/pom.xml index 3587cf3adb..e8d8732d11 100644 --- a/pom.xml +++ b/pom.xml @@ -85,6 +85,7 @@ 1.6.7 0.5.0 1.12.1 + 2.4.0 sermant-agent ${pom.basedir} diff --git a/sermant-agentcore/sermant-agentcore-core/src/main/java/io/sermant/core/service/dynamicconfig/common/DynamicConfigServiceType.java b/sermant-agentcore/sermant-agentcore-core/src/main/java/io/sermant/core/service/dynamicconfig/common/DynamicConfigServiceType.java index 8df3c8191c..04af931cc4 100644 --- a/sermant-agentcore/sermant-agentcore-core/src/main/java/io/sermant/core/service/dynamicconfig/common/DynamicConfigServiceType.java +++ b/sermant-agentcore/sermant-agentcore-core/src/main/java/io/sermant/core/service/dynamicconfig/common/DynamicConfigServiceType.java @@ -23,6 +23,11 @@ * @since 2021-12-27 */ public enum DynamicConfigServiceType { + + /** + * apollo configuration center + */ + APOLLO, /** * zookeeper configuration center */ diff --git a/sermant-agentcore/sermant-agentcore-implement/pom.xml b/sermant-agentcore/sermant-agentcore-implement/pom.xml index 4d1bf01136..a4c2758944 100644 --- a/sermant-agentcore/sermant-agentcore-implement/pom.xml +++ b/sermant-agentcore/sermant-agentcore-implement/pom.xml @@ -126,6 +126,21 @@ zookeeper ${zookeeper.version} + + com.ctrip.framework.apollo + apollo-client + ${apollo.version} + + + com.ctrip.framework.apollo + apollo-openapi + ${apollo.version} + + + com.ctrip.framework.apollo + apollo-core + ${apollo.version} + com.alibaba.nacos nacos-client @@ -345,6 +360,50 @@ + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + apollo-test-isolated + test + + test + + + 1 + false + + **/ApolloDynamicConfigServiceTest.java + + + + + + + + default-test + test + + test + + + 1 + true + + **/ApolloDynamicConfigServiceTest.java + + + **/*Test.java + **/*Tests.java + **/*TestCase.java + + + + + diff --git a/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/BufferedDynamicConfigService.java b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/BufferedDynamicConfigService.java index e1876d5b66..d161f0af33 100644 --- a/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/BufferedDynamicConfigService.java +++ b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/BufferedDynamicConfigService.java @@ -18,6 +18,7 @@ import io.sermant.core.service.dynamicconfig.DynamicConfigService; import io.sermant.core.service.dynamicconfig.common.DynamicConfigListener; +import io.sermant.implement.service.dynamicconfig.apollo.ApolloDynamicConfigService; import io.sermant.implement.service.dynamicconfig.kie.KieDynamicConfigService; import io.sermant.implement.service.dynamicconfig.nacos.NacosDynamicConfigService; import io.sermant.implement.service.dynamicconfig.zookeeper.ZooKeeperDynamicConfigService; @@ -50,6 +51,9 @@ public BufferedDynamicConfigService() { case NACOS: service = new NacosDynamicConfigService(); break; + case APOLLO: + service = new ApolloDynamicConfigService(); + break; default: service = new ZooKeeperDynamicConfigService(); } diff --git a/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloBufferedClient.java b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloBufferedClient.java new file mode 100644 index 0000000000..839f8f59cd --- /dev/null +++ b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloBufferedClient.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2025-2025 Sermant Authors. All rights reserved. + * + * 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.sermant.implement.service.dynamicconfig.apollo; + +import com.ctrip.framework.apollo.ConfigChangeListener; + +import java.io.Closeable; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +/** + * wraps the Apollo open apis and client to provides easier apis + * + * @author Chen Zhenyang + * @since 2025-08-07 + */ +public class ApolloBufferedClient implements Closeable { + private static final Logger LOGGER = Logger.getLogger(ApolloBufferedClient.class.getName()); + private final ApolloClient apolloClient; + + /** + * constructor + */ + public ApolloBufferedClient() { + apolloClient = new ApolloClient(); + } + + @Override + public void close() { + apolloClient.close(); + } + + /** + * publish config + * + * @param key apollo key + * @param group apollo namespace + * @param content apollo value + * @return check if publish success + */ + public boolean publishConfig(String key, String group, String content) { + return apolloClient.publishConfig(key, group, content); + } + + /** + * remove config + * + * @param key apollo key + * @param group apollo namespace + * @return check if remove success + */ + public boolean removeConfig(String key, String group) { + return apolloClient.removeConfig(key, group); + } + + /** + * get key list from group + * + * @param group apollo namespace + * @return List of keys from group + */ + public List getListKeysFromGroup(String group) { + Map> res = apolloClient.getConfigList(null, group, true); + return res.get(group); + } + + /** + * get config + * + * @param key apollo key + * @param group apollo namespace + * @return value of the key + */ + public String getConfig(String key, String group) { + return apolloClient.getConfig(key, group); + } + + /** + * add group listener + * + * @param group apollo namespace + * @param apolloListener apollo listener + * @return check if add group listener success + */ + public boolean addGroupListener(String group, ConfigChangeListener apolloListener) { + return apolloClient.addGroupListener(group, apolloListener); + } + + /** + * add config listener + * + * @param key apollo key + * @param group apollo namespace + * @param apolloListener apollo listener + * @return check if add config listener success + */ + public boolean addConfigListener(String key, String group, ConfigChangeListener apolloListener) { + return apolloClient.addConfigListener(key, group, apolloListener); + } + + /** + * remove listener + * + * @param validGroup apollo namespace + * @param ccl apollo listener + * @return check if remove listener success + */ + public boolean removeConfigListener(String validGroup, ConfigChangeListener ccl) { + return apolloClient.removeConfigListener(validGroup, ccl); + } +} diff --git a/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloClient.java b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloClient.java new file mode 100644 index 0000000000..cca4f3be7c --- /dev/null +++ b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloClient.java @@ -0,0 +1,335 @@ +/* + * Copyright (C) 2025-2025 Sermant Authors. All rights reserved. + * + * 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.sermant.implement.service.dynamicconfig.apollo; + +import com.ctrip.framework.apollo.Config; +import com.ctrip.framework.apollo.ConfigChangeListener; +import com.ctrip.framework.apollo.ConfigService; +import com.ctrip.framework.apollo.openapi.client.ApolloOpenApiClient; +import com.ctrip.framework.apollo.openapi.client.exception.ApolloOpenApiException; +import com.ctrip.framework.apollo.openapi.dto.NamespaceReleaseDTO; +import com.ctrip.framework.apollo.openapi.dto.OpenAppNamespaceDTO; +import com.ctrip.framework.apollo.openapi.dto.OpenItemDTO; +import com.ctrip.framework.apollo.openapi.dto.OpenNamespaceDTO; + +import io.sermant.core.utils.StringUtils; +import io.sermant.implement.service.dynamicconfig.ConfigClient; +import io.sermant.implement.service.dynamicconfig.apollo.config.ApolloProperty; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * wraps the Apollo open apis and client to provides easier apis + * + * @author Chen Zhenyang + * @since 2025-08-07 + */ +public class ApolloClient implements ConfigClient { + private static final Logger LOGGER = Logger.getLogger(ApolloClient.class.getName()); + private static final String PUBLISH_COMMENT_FORMAT = + "publish from sermant, operate_type=%s user=%s app=%s env=%s cluster=%s time=%s"; + private final ApolloOpenApiClient apolloOpenApiClient; + + // patterns of fuzzy query + private final Map patternMap = new ConcurrentHashMap<>(); + + /** + * constructor + */ + public ApolloClient() { + apolloOpenApiClient = ApolloOpenApiClient.newBuilder() + .withPortalUrl(ApolloProperty.getAdminUrl()) + .withToken(ApolloProperty.getToken()) + .build(); + + // simple call to initialize apollo client config. + ConfigService.getAppConfig(); + } + + @Override + public String getConfig(String key, String group) { + Config config = ConfigService.getConfig(group); + return config.getProperty(key, ""); + } + + @Override + public Map> getConfigList(String key, String group, boolean accurateFlag) { + Map> configList = new HashMap<>(); + try { + if (!accurateFlag) { + List namespaces = apolloOpenApiClient.getNamespaces(ApolloProperty.getAppId(), + ApolloProperty.getEnv(), ApolloProperty.getCluster(), true); + Pattern groupPattern = patternMap.computeIfAbsent(group, patternKey -> Pattern.compile(group)); + Pattern keyPattern = patternMap.computeIfAbsent(key, patternKey -> Pattern.compile(key)); + for (OpenNamespaceDTO namespace : namespaces) { + if (!groupPattern.matcher(namespace.getNamespaceName()).matches()) { + continue; + } + List list = new ArrayList<>(); + for (OpenItemDTO item : namespace.getItems()) { + if (StringUtils.isNoneBlank(key) && !keyPattern.matcher(item.getKey()).matches()) { + continue; + } + list.add(item.getKey()); + } + if (!list.isEmpty()) { + configList.put(namespace.getNamespaceName(), list); + } + } + } else { + if (key == null || key.isEmpty()) { + OpenNamespaceDTO namespace = apolloOpenApiClient.getNamespace(ApolloProperty.getAppId(), + ApolloProperty.getEnv(), ApolloProperty.getCluster(), group, true); + if (namespace == null) { + return Collections.EMPTY_MAP; + } + List items = namespace.getItems(); + List list = items.stream().map(OpenItemDTO::getKey).collect(Collectors.toList()); + configList.put(group, list); + return configList; + } + OpenItemDTO item = apolloOpenApiClient.getItem(ApolloProperty.getAppId(), ApolloProperty.getEnv(), + ApolloProperty.getCluster(), group, key); + if (item == null) { + return Collections.EMPTY_MAP; + } + List list = new ArrayList<>(); + list.add(item.getKey()); + configList.put(group, list); + } + } catch (RuntimeException e) { + LOGGER.log(Level.SEVERE, + String.format(Locale.ROOT, "Exception in querying configuration list of group:%s", group), e); + } + return configList; + } + + @Override + public boolean publishConfig(String key, String group, String content) { + try { + ensureNamespace(group); + OpenItemDTO openItemDto = new OpenItemDTO(); + openItemDto.setKey(key); + openItemDto.setValue(content); + openItemDto.setDataChangeCreatedBy(ApolloProperty.getUser()); + openItemDto.setDataChangeLastModifiedBy(ApolloProperty.getUser()); + apolloOpenApiClient.createOrUpdateItem(ApolloProperty.getAppId(), ApolloProperty.getEnv(), + ApolloProperty.getCluster(), group, openItemDto); + String comment = String.format(Locale.ROOT, PUBLISH_COMMENT_FORMAT, + String.format(Locale.ROOT, "publish %s %s", group, key), + ApolloProperty.getUser(), ApolloProperty.getAppId(), ApolloProperty.getEnv(), + ApolloProperty.getCluster(), LocalDateTime.now()); + publishApollo(group, String.format(Locale.ROOT, "publish %s %s", group, key), comment); + return true; + } catch (RuntimeException e) { + LOGGER.log(Level.SEVERE, String.format(Locale.ROOT, + "Exception in publishing config group:%s key:%s", group, key), e); + return false; + } + } + + /** + * if namespace doesn't exist, create one + * + * @param namespace namespace of apollo + */ + private void ensureNamespace(String namespace) { + if (existsNamespace(namespace)) { + return; + } + try { + OpenAppNamespaceDTO ns = new OpenAppNamespaceDTO(); + ns.setAppId(ApolloProperty.getAppId()); + ns.setName(namespace); + ns.setPublic(false); + ns.setComment(String.format(Locale.ROOT, "Namespace:%s is created by sermant", namespace)); + ns.setDataChangeCreatedBy(ApolloProperty.getUser()); + apolloOpenApiClient.createAppNamespace(ns); + } catch (RuntimeException e) { + if (e.getCause() instanceof ApolloOpenApiException) { + ApolloOpenApiException apolloException = (ApolloOpenApiException) e.getCause(); + if (apolloException.getStatus() == HttpURLConnection.HTTP_BAD_REQUEST + || apolloException.getStatus() == HttpURLConnection.HTTP_CONFLICT) { + LOGGER.info(String.format(Locale.ROOT, "Namespace %s is already created", namespace)); + } + } + LOGGER.log(Level.SEVERE, String.format(Locale.ROOT, "Exception in creating namespace:%s", namespace), e); + throw e; + } + } + + private boolean existsNamespace(String namespace) { + try { + apolloOpenApiClient.getNamespace(ApolloProperty.getAppId(), ApolloProperty.getEnv(), + ApolloProperty.getCluster(), namespace); + return true; + } catch (RuntimeException e) { + if (e.getCause() instanceof ApolloOpenApiException + && ((ApolloOpenApiException) e.getCause()).getStatus() == HttpURLConnection.HTTP_NOT_FOUND) { + return false; + } + LOGGER.log(Level.SEVERE, String.format(Locale.ROOT, "Exception in creating namespace:%s", namespace), e); + throw e; + } + } + + @Override + public boolean removeConfig(String key, String group) { + try { + apolloOpenApiClient.removeItem(ApolloProperty.getAppId(), ApolloProperty.getEnv(), + ApolloProperty.getCluster(), group, key, ApolloProperty.getUser()); + + String comment = String.format(Locale.ROOT, PUBLISH_COMMENT_FORMAT, + String.format(Locale.ROOT, "remove %s %s", group, key), + ApolloProperty.getUser(), ApolloProperty.getAppId(), ApolloProperty.getEnv(), + ApolloProperty.getCluster(), LocalDateTime.now()); + publishApollo(group, String.format(Locale.ROOT, "remove %s %s", group, key), comment); + return true; + } catch (RuntimeException e) { + if (e.getCause() instanceof ApolloOpenApiException + && ((ApolloOpenApiException) e.getCause()).getStatus() == HttpURLConnection.HTTP_NOT_FOUND) { + return false; + } + LOGGER.log(Level.SEVERE, String.format(Locale.ROOT, "Exception in removing config group:%s key:%s", + group, key), e); + return false; + } + } + + private void publishApollo(String group, String title, String comment) { + // if double check is required, the publish operation must be performed by a user different from the editor + if (ApolloProperty.isDoubleCheck()) { + LOGGER.info(String.format(Locale.ROOT, "Modification of group:%s needs double check.Details:%s", + group, comment)); + return; + } + try { + NamespaceReleaseDTO namespaceReleaseDto = new NamespaceReleaseDTO(); + namespaceReleaseDto.setReleasedBy(ApolloProperty.getUser()); + namespaceReleaseDto.setReleaseTitle(title); + namespaceReleaseDto.setReleaseComment(comment); + apolloOpenApiClient.publishNamespace(ApolloProperty.getAppId(), ApolloProperty.getEnv(), + ApolloProperty.getCluster(), group, namespaceReleaseDto); + } catch (RuntimeException e) { + LOGGER.log(Level.SEVERE, String.format(Locale.ROOT, "Failed to release namespace:%s", group), e); + throw e; + } + } + + @Override + public boolean isConnect() { + return isAvailable(ApolloProperty.getUrl()) && isAvailable(ApolloProperty.getAdminUrl()); + } + + /** + * check url is available + * + * @param configServerUrl url to server + * @return check if url is available + */ + public boolean isAvailable(String configServerUrl) { + HttpURLConnection connection = null; + try { + URL url = new URL(configServerUrl + "/health"); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("HEAD"); + connection.setConnectTimeout(ApolloProperty.getConnectTimeout()); + connection.setReadTimeout(ApolloProperty.getReadTimeout()); + int responseCode = getResponse(connection); + return responseCode >= HttpURLConnection.HTTP_OK && responseCode < HttpURLConnection.HTTP_MULT_CHOICE; + } catch (IOException e) { + return false; + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + /** + * get http response code + * + * @param connection http connection + * @return http code + * @throws IOException http connection exception + */ + int getResponse(HttpURLConnection connection) throws IOException { + return connection.getResponseCode(); + } + + /** + * close the client + */ + public void close() { + } + + /** + * add group listener + * + * @param group valid namespace + * @param apolloListener listener + * @return check if add listener successfully + */ + public boolean addGroupListener(String group, ConfigChangeListener apolloListener) { + Config config = ConfigService.getConfig(group); + config.addChangeListener(apolloListener); + return true; + } + + /** + * listen to interested key only + * + * @param key apollo key + * @param group apollo valid namespace + * @param apolloListener listener + * @return add result + */ + public boolean addConfigListener(String key, String group, ConfigChangeListener apolloListener) { + Config config = ConfigService.getConfig(group); + HashSet interestedKeys = new HashSet<>(); + interestedKeys.add(key); + config.addChangeListener(apolloListener, interestedKeys); + return true; + } + + /** + * remove config listener on the namespace + * + * @param group valid namespace + * @param listener listener + * @return check if remove listener successfully + */ + public boolean removeConfigListener(String group, ConfigChangeListener listener) { + Config config = ConfigService.getConfig(group); + return config.removeChangeListener(listener); + } +} diff --git a/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloDynamicConfigService.java b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloDynamicConfigService.java new file mode 100644 index 0000000000..bda6f1a99d --- /dev/null +++ b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloDynamicConfigService.java @@ -0,0 +1,294 @@ +/* + * Copyright (C) 2025-2025 Sermant Authors. All rights reserved. + * + * 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.sermant.implement.service.dynamicconfig.apollo; + +import com.ctrip.framework.apollo.ConfigChangeListener; +import com.ctrip.framework.apollo.model.ConfigChange; + +import io.sermant.core.config.ConfigManager; +import io.sermant.core.plugin.config.ServiceMeta; +import io.sermant.core.service.dynamicconfig.DynamicConfigService; +import io.sermant.core.service.dynamicconfig.common.DynamicConfigEvent; +import io.sermant.core.service.dynamicconfig.common.DynamicConfigListener; +import io.sermant.core.utils.CollectionUtils; +import io.sermant.implement.service.dynamicconfig.apollo.config.ApolloProperty; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Dynamic configuration service, Apollo implementation + * + * @author Chen Zhenyang + * @since 2025-08-07 + */ +public class ApolloDynamicConfigService extends DynamicConfigService { + /** + * logger + */ + private static final Logger LOGGER = Logger.getLogger(ApolloDynamicConfigService.class.getName()); + + private ApolloBufferedClient apolloClient; + + private final ServiceMeta serviceMeta; + + private final Map> groupListeners = new ConcurrentHashMap<>(); + + private final Map>> keyListeners = new ConcurrentHashMap<>(); + + /** + * constructor + */ + public ApolloDynamicConfigService() { + serviceMeta = ConfigManager.getConfig(ServiceMeta.class); + } + + @Override + public void start() { + HashMap params = new HashMap(); + params.put("app_id", serviceMeta.getApplication()); + params.put("cluster", serviceMeta.getParameters().get("cluster")); + params.put("env", serviceMeta.getEnvironment()); + params.put("url", CONFIG.getServerAddress()); + params.put("access_key", CONFIG.getPrivateKey()); + params.put("token", serviceMeta.getParameters().get("token")); + params.put("read_timeout", String.valueOf(CONFIG.getTimeoutValue())); + params.put("connect_timeout", String.valueOf(CONFIG.getConnectTimeout())); + params.put("admin_url", serviceMeta.getParameters().get("adminUrl")); + params.put("user", CONFIG.getUserName()); + params.put("double_check", serviceMeta.getParameters().get("isDoubleCheck")); + ApolloProperty.setProperties(params); + apolloClient = new ApolloBufferedClient(); + } + + @Override + public void stop() { + apolloClient.close(); + } + + @Override + public Optional doGetConfig(String key, String group) { + if (!ApolloUtils.isValidNamespace(group)) { + LOGGER.log(Level.SEVERE, "apollo get config failed, group name is invalid. group: {0}", group); + return Optional.empty(); + } + String validGroup = ApolloUtils.rebuildGroup(group); + Optional config = Optional.ofNullable(apolloClient.getConfig(key, validGroup)); + + LOGGER.log(Level.INFO, "apollo config get success, key: {0}, group: {1}", + new String[]{key, validGroup}); + return config; + } + + @Override + public boolean doPublishConfig(String key, String group, String content) { + if (!ApolloUtils.isValidNamespace(group)) { + LOGGER.log(Level.SEVERE, "apollo publish config failed, group name is invalid. group: {0}", group); + return false; + } + String validGroup = ApolloUtils.rebuildGroup(group); + + boolean result = apolloClient.publishConfig(key, validGroup, content); + if (result) { + LOGGER.log(Level.INFO, "apollo config publish success, key: {0}, group: {1}, content: {2}", + new String[]{key, validGroup, content}); + } else { + LOGGER.log(Level.SEVERE, "apollo config publish failed, key: {0}, group: {1}, content: {2}", + new String[]{key, validGroup, content}); + } + return result; + } + + @Override + public boolean doRemoveConfig(String key, String group) { + if (!ApolloUtils.isValidNamespace(group)) { + LOGGER.log(Level.SEVERE, "apollo remove config failed, group name is invalid. group: {0}", group); + return false; + } + String validGroup = ApolloUtils.rebuildGroup(group); + + boolean result = apolloClient.removeConfig(key, validGroup); + if (result) { + LOGGER.log(Level.INFO, "apollo config remove success, key: {0}, group: {1}", + new String[]{key, validGroup}); + } else { + LOGGER.log(Level.SEVERE, "apollo config remove failed, key: {0}, group: {1}", + new String[]{key, validGroup}); + } + return result; + } + + @Override + public boolean doAddConfigListener(String key, String group, DynamicConfigListener listener) { + if (!ApolloUtils.isValidNamespace(group)) { + LOGGER.log(Level.SEVERE, "apollo config listener add failed, group name is invalid. group: {0}", group); + return false; + } + String validGroup = ApolloUtils.rebuildGroup(group); + ConfigChangeListener apolloListener = event -> { + for (String k : event.changedKeys()) { + if (k.equals(key)) { + listener.process(transEvent(k, group, event.getChange(k))); + } + } + }; + boolean result = apolloClient.addConfigListener(key, validGroup, apolloListener); + if (result) { + keyListeners.putIfAbsent(validGroup, new ConcurrentHashMap<>()); + Map> keyListener = keyListeners.get(validGroup); + List listenerList = keyListener.getOrDefault(key, new CopyOnWriteArrayList<>()); + listenerList.add(apolloListener); + keyListener.put(key, listenerList); + LOGGER.log(Level.INFO, "apollo config listener add success, key: {0}, group: {1}", + new String[]{key, validGroup}); + } else { + LOGGER.log(Level.SEVERE, "apollo config listener add failed, key: {0}, group: {1}", + new String[]{key, validGroup}); + } + return result; + } + + /** + * transform apollo event to sermant inner event + * + * @param key key of apollo + * @param group sermant group + * @param change change type + * @return sermant dynamic config event + */ + public DynamicConfigEvent transEvent(String key, String group, ConfigChange change) { + switch (change.getChangeType()) { + case ADDED: { + return DynamicConfigEvent.createEvent(key, group, change.getNewValue()); + } + case DELETED: { + return DynamicConfigEvent.deleteEvent(key, group, null); + } + case MODIFIED: + default: { + return DynamicConfigEvent.modifyEvent(key, group, change.getNewValue()); + } + } + } + + @Override + public boolean doRemoveConfigListener(String key, String group) { + if (!ApolloUtils.isValidNamespace(group)) { + LOGGER.log(Level.SEVERE, "apollo config listener remove failed, group name is invalid. group: {0}", group); + return false; + } + String validGroup = ApolloUtils.rebuildGroup(group); + Map> keyListener = keyListeners.getOrDefault(validGroup, null); + if (keyListener == null) { + return true; + } + List listenerList = keyListener.getOrDefault(key, null); + if (listenerList == null) { + return true; + } + for (ConfigChangeListener listener : listenerList) { + if (!apolloClient.removeConfigListener(validGroup, listener)) { + LOGGER.log(Level.SEVERE, "apollo config listener remove failed. key: {0}, group: {1}", + new String[]{key, validGroup}); + } + } + keyListener.remove(key); + if (keyListener.isEmpty()) { + keyListeners.remove(validGroup); + } + LOGGER.log(Level.INFO, "apollo config listener remove success, key: {0}, group: {1}", + new String[]{key, validGroup}); + return true; + } + + @Override + public boolean doAddGroupListener(String group, DynamicConfigListener listener) { + if (!ApolloUtils.isValidNamespace(group)) { + LOGGER.log(Level.SEVERE, "apollo group listener add failed, group name is invalid. group: {0}", group); + return false; + } + String validGroup = ApolloUtils.rebuildGroup(group); + ConfigChangeListener apolloListener = event -> { + for (String k : event.changedKeys()) { + listener.process(transEvent(k, group, event.getChange(k))); + } + }; + boolean result = apolloClient.addGroupListener(group, apolloListener); + if (result) { + groupListeners.putIfAbsent(validGroup, new CopyOnWriteArrayList<>()); + List listeners = groupListeners.get(validGroup); + + listeners.add(apolloListener); + LOGGER.log(Level.INFO, "apollo group listener add success, group: {0}", + new String[]{validGroup}); + } else { + LOGGER.log(Level.SEVERE, "apollo group listener add failed, group: {0}", + new String[]{validGroup}); + } + return result; + } + + @Override + public boolean doRemoveGroupListener(String group) { + if (!ApolloUtils.isValidNamespace(group)) { + LOGGER.log(Level.SEVERE, "apollo group listener remove failed, group name is invalid. group: {0}", group); + return false; + } + String validGroup = ApolloUtils.rebuildGroup(group); + List listeners = new CopyOnWriteArrayList<>(); + if (groupListeners.containsKey(validGroup)) { + listeners.addAll(groupListeners.get(validGroup)); + groupListeners.remove(validGroup); + } + if (keyListeners.containsKey(validGroup)) { + Map> stringSetMap = keyListeners.get(validGroup); + for (String key : stringSetMap.keySet()) { + listeners.addAll(stringSetMap.get(key)); + stringSetMap.remove(key); + } + keyListeners.remove(validGroup); + } + + for (ConfigChangeListener ccl : listeners) { + if (!apolloClient.removeConfigListener(validGroup, ccl)) { + LOGGER.log(Level.SEVERE, "apollo group listener remove failed, group: {0}", new String[]{validGroup}); + } + } + LOGGER.log(Level.INFO, "apollo group listener remove success, group: {0}", new String[]{validGroup}); + return true; + } + + @Override + public List doListKeysFromGroup(String group) { + if (!ApolloUtils.isValidNamespace(group)) { + LOGGER.log(Level.SEVERE, "Apollo list keys from group failed, group name is invalid. group: {0}", + group); + return Collections.emptyList(); + } + String validGroup = ApolloUtils.rebuildGroup(group); + List resultList = apolloClient.getListKeysFromGroup(validGroup); + LOGGER.log(Level.INFO, "apollo config list get success, group: {0}", new String[]{validGroup}); + return CollectionUtils.isEmpty(resultList) ? Collections.emptyList() : resultList; + } +} diff --git a/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloUtils.java b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloUtils.java new file mode 100644 index 0000000000..ffa3528be6 --- /dev/null +++ b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloUtils.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2025-2025 Sermant Authors. All rights reserved. + * + * 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.sermant.implement.service.dynamicconfig.apollo; + +import java.util.regex.Pattern; + +/** + * apollo tool class + * + * @author Chen Zhenyang + * @since 2025-08-18 + */ +public class ApolloUtils { + private static final String BASE_VALID_REGEX = "^(?!^[./]$)[a-zA-Z0-9\\-_.&=/]+$"; + + private static final String FORBIDDEN_SUFFIX_REGEX = ".*[./](json|yml|yaml|xml|properties)$"; + private static final Pattern BASE_VALID_PATTERN = Pattern.compile(BASE_VALID_REGEX); + private static final Pattern FORBIDDEN_SUFFIX_PATTERN = Pattern.compile(FORBIDDEN_SUFFIX_REGEX); + + private ApolloUtils() { + } + + /** + * Check whether the group name is valid + * 1. supports only English and digit characters and three special symbols ('.','-','_') + * 2. not supports single "." or "/", and it cannot end with ".json", ".yml", ".yaml", + * ".xml", or ".properties". + * + * @param namespace group name + * @return true: valid; false: invalid + */ + public static boolean isValidNamespace(String namespace) { + if (namespace == null || namespace.isEmpty()) { + return false; + } + return BASE_VALID_PATTERN.matcher(namespace).matches() + && !FORBIDDEN_SUFFIX_PATTERN.matcher(namespace).matches(); + } + + /** + * Rebuild the valid group name. + * + * @param group group name + * @return valid group + */ + public static String rebuildGroup(String group) { + return group.replace('=', '_').replace('&', '-').replace('/', '.'); + } + + /** + * convert the group name to apollo group + * + * @param group valid apollo name + * @return group name + */ + public static String convertGroup(String group) { + return group.replace('_', '=').replace('-', '&').replace('.', '/'); + } +} diff --git a/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloApplicationProvider.java b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloApplicationProvider.java new file mode 100644 index 0000000000..371de03b27 --- /dev/null +++ b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloApplicationProvider.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2025-2025 Sermant Authors. All rights reserved. + * + * 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.sermant.implement.service.dynamicconfig.apollo.config; + +import com.ctrip.framework.foundation.internals.provider.DefaultApplicationProvider; + +import io.sermant.core.utils.StringUtils; + +/** + * customized application provider of apollo + * + * @author Chen Zhenyang + * @since 2025-08-14 + */ +public class ApolloApplicationProvider extends DefaultApplicationProvider { + private static final String APP_ID = "app.id"; + private static final String ACCESS_KEY = "apollo.access-key.secret"; + + @Override + public String getAccessKeySecret() { + String value = ApolloProperty.getAccessKey(); + return StringUtils.isNoneBlank(value) ? value : super.getAccessKeySecret(); + } + + @Override + public String getAccessKeySecret(String appId) { + if (appId.equals(getAppId())) { + return getAccessKeySecret(); + } + return super.getAccessKeySecret(appId); + } + + @Override + public String getAppId() { + String value = ApolloProperty.getAppId(); + return StringUtils.isNoneBlank(value) ? value : super.getAppId(); + } + + @Override + public boolean isAppIdSet() { + return StringUtils.isNoneBlank(getAppId()); + } + + @Override + public String getProperty(String name, String defaultValue) { + String val; + if (APP_ID.equals(name)) { + val = getAppId(); + return StringUtils.isNoneBlank(val) ? val : defaultValue; + } else if (ACCESS_KEY.equals(name)) { + val = getAccessKeySecret(); + return StringUtils.isNoneBlank(val) ? val : defaultValue; + } else { + val = super.getProperty(name, defaultValue); + } + return val; + } +} diff --git a/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloMetaServerProvider.java b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloMetaServerProvider.java new file mode 100644 index 0000000000..3c7647975a --- /dev/null +++ b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloMetaServerProvider.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2025-2025 Sermant Authors. All rights reserved. + * + * 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.sermant.implement.service.dynamicconfig.apollo.config; + +import com.ctrip.framework.apollo.core.enums.Env; +import com.ctrip.framework.apollo.internals.DefaultMetaServerProvider; + +import io.sermant.core.utils.StringUtils; + +/** + * customized meta server provider of apollo + * + * @author Chen Zhenyang + * @since 2025-08-17 + */ +public class ApolloMetaServerProvider extends DefaultMetaServerProvider { + + @Override + public String getMetaServerAddress(Env env) { + String val = ApolloProperty.getUrl(); + return StringUtils.isNoneBlank(val) ? val : super.getMetaServerAddress(env); + } + + /** + * priority is higher than DefaultMetaServerProvider but not the highest. + * + * @return priority of provider + */ + @Override + public int getOrder() { + return -1; + } +} diff --git a/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloProperty.java b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloProperty.java new file mode 100644 index 0000000000..9e24a6aefb --- /dev/null +++ b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloProperty.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2025-2025 Sermant Authors. All rights reserved. + * + * 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.sermant.implement.service.dynamicconfig.apollo.config; + +import java.util.Map; + +/** + * aggregate configurations for apollo. + * + * @author Chen Zhenyang + * @since 2025-08-14 + */ +public class ApolloProperty { + + private static volatile String appId; + private static volatile String cluster; + private static volatile String env; + private static volatile String url; + + // authentication for config service, users can choose to open + private static volatile String accessKey; + + // url for open api + private static volatile String adminUrl; + + // authentication for open api, which is necessary + private static volatile String token; + private static volatile String user; + + // check if the publish operation must be performed by a user different from the editor + private static volatile boolean doubleCheck; + private static volatile int readTimeout; + private static volatile int connectTimeout; + + /** + * constructor + */ + private ApolloProperty() { + } + + /** + * set apollo meta configs + * + * @param params apollo config params + */ + public static void setProperties(Map params) { + appId = params.get("app_id"); + cluster = params.get("cluster"); + env = params.get("env"); + url = params.get("url"); + accessKey = params.get("access_key"); + token = params.get("token"); + readTimeout = Integer.parseInt(params.get("read_timeout")); + connectTimeout = Integer.parseInt(params.get("connect_timeout")); + adminUrl = params.get("admin_url"); + user = params.get("user"); + doubleCheck = Boolean.parseBoolean(params.get("double_check")); + } + + public static int getReadTimeout() { + return readTimeout; + } + + public static int getConnectTimeout() { + return connectTimeout; + } + + public static String getToken() { + return token; + } + + public static String getAccessKey() { + return accessKey; + } + + public static String getAppId() { + return appId; + } + + public static String getCluster() { + return cluster; + } + + public static String getUser() { + return user; + } + + public static String getUrl() { + return url; + } + + public static String getEnv() { + return env; + } + + public static String getAdminUrl() { + return adminUrl; + } + + public static boolean isDoubleCheck() { + return doubleCheck; + } +} diff --git a/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloProviderManager.java b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloProviderManager.java new file mode 100644 index 0000000000..2b70d5c614 --- /dev/null +++ b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloProviderManager.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2025-2025 Sermant Authors. All rights reserved. + * + * 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.sermant.implement.service.dynamicconfig.apollo.config; + +import com.ctrip.framework.foundation.internals.DefaultProviderManager; +import com.ctrip.framework.foundation.internals.provider.DefaultNetworkProvider; +import com.ctrip.framework.foundation.spi.provider.Provider; + +/** + * Override the initialize method to load customized providers + * + * @author Chen Zhenyang + * @since 2025-08-14 + */ +public class ApolloProviderManager extends DefaultProviderManager { + /** + * initialize customized providers + */ + @Override + public void initialize() { + Provider applicationProvider = new ApolloApplicationProvider(); + applicationProvider.initialize(); + this.register(applicationProvider); + Provider networkProvider = new DefaultNetworkProvider(); + networkProvider.initialize(); + this.register(networkProvider); + Provider serverProvider = new ApolloServerProvider(); + serverProvider.initialize(); + this.register(serverProvider); + } +} diff --git a/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloServerProvider.java b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloServerProvider.java new file mode 100644 index 0000000000..02edede917 --- /dev/null +++ b/sermant-agentcore/sermant-agentcore-implement/src/main/java/io/sermant/implement/service/dynamicconfig/apollo/config/ApolloServerProvider.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2025-2025 Sermant Authors. All rights reserved. + * + * 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.sermant.implement.service.dynamicconfig.apollo.config; + +import com.ctrip.framework.foundation.internals.provider.DefaultServerProvider; + +import io.sermant.core.utils.StringUtils; + +/** + * customized server provider of apollo + * + * @author Chen Zhenyang + * @since 2025-08-14 + */ +public class ApolloServerProvider extends DefaultServerProvider { + private static final String APOLLO_CLUSTER = "apollo.cluster"; + + @Override + public String getEnvType() { + String envType = ApolloProperty.getEnv(); + return envType != null ? envType : super.getEnvType(); + } + + @Override + public boolean isEnvTypeSet() { + return StringUtils.isNoneBlank(ApolloProperty.getEnv()) || super.isEnvTypeSet(); + } + + /** + * get meta property from server provider + * + * @param name key of property + * @param defaultValue default value + * @return value of property + */ + @Override + public String getProperty(String name, String defaultValue) { + if (APOLLO_CLUSTER.equals(name)) { + String cluster = ApolloProperty.getCluster(); + if (StringUtils.isNoneBlank(cluster)) { + return cluster; + } + } + return super.getProperty(name, defaultValue); + } +} diff --git a/sermant-agentcore/sermant-agentcore-implement/src/main/resources/META-INF/services/com.ctrip.framework.apollo.core.spi.MetaServerProvider b/sermant-agentcore/sermant-agentcore-implement/src/main/resources/META-INF/services/com.ctrip.framework.apollo.core.spi.MetaServerProvider new file mode 100644 index 0000000000..c7d52d232d --- /dev/null +++ b/sermant-agentcore/sermant-agentcore-implement/src/main/resources/META-INF/services/com.ctrip.framework.apollo.core.spi.MetaServerProvider @@ -0,0 +1 @@ +io.sermant.implement.service.dynamicconfig.apollo.config.ApolloMetaServerProvider diff --git a/sermant-agentcore/sermant-agentcore-implement/src/main/resources/META-INF/services/com.ctrip.framework.foundation.spi.ProviderManager b/sermant-agentcore/sermant-agentcore-implement/src/main/resources/META-INF/services/com.ctrip.framework.foundation.spi.ProviderManager new file mode 100644 index 0000000000..b86bb8b595 --- /dev/null +++ b/sermant-agentcore/sermant-agentcore-implement/src/main/resources/META-INF/services/com.ctrip.framework.foundation.spi.ProviderManager @@ -0,0 +1 @@ +io.sermant.implement.service.dynamicconfig.apollo.config.ApolloProviderManager diff --git a/sermant-agentcore/sermant-agentcore-implement/src/test/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloDynamicConfigServiceTest.java b/sermant-agentcore/sermant-agentcore-implement/src/test/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloDynamicConfigServiceTest.java new file mode 100644 index 0000000000..9dff372ed4 --- /dev/null +++ b/sermant-agentcore/sermant-agentcore-implement/src/test/java/io/sermant/implement/service/dynamicconfig/apollo/ApolloDynamicConfigServiceTest.java @@ -0,0 +1,456 @@ +/* + * Copyright (C) 2025-2025 Sermant Authors. All rights reserved. + * + * 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.sermant.implement.service.dynamicconfig.apollo; + +import com.ctrip.framework.apollo.Config; +import com.ctrip.framework.apollo.ConfigChangeListener; +import com.ctrip.framework.apollo.ConfigService; +import com.ctrip.framework.apollo.core.enums.Env; +import com.ctrip.framework.apollo.enums.PropertyChangeType; +import com.ctrip.framework.apollo.model.ConfigChange; +import com.ctrip.framework.apollo.model.ConfigChangeEvent; +import com.ctrip.framework.apollo.openapi.client.ApolloOpenApiClient; +import com.ctrip.framework.apollo.openapi.client.exception.ApolloOpenApiException; +import com.ctrip.framework.apollo.openapi.dto.OpenAppNamespaceDTO; +import com.ctrip.framework.apollo.openapi.dto.OpenItemDTO; +import com.ctrip.framework.apollo.openapi.dto.OpenNamespaceDTO; +import io.sermant.core.service.dynamicconfig.common.DynamicConfigEvent; +import io.sermant.core.service.dynamicconfig.common.DynamicConfigListener; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import io.sermant.core.config.ConfigManager; +import io.sermant.core.plugin.config.ServiceMeta; +import io.sermant.core.service.ServiceManager; +import io.sermant.core.service.dynamicconfig.config.DynamicConfig; +import io.sermant.implement.service.dynamicconfig.apollo.config.ApolloProviderManager; +import io.sermant.implement.service.dynamicconfig.apollo.config.ApolloMetaServerProvider; + +import java.net.HttpURLConnection; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +/** + * @author Chen Zhenyang + * @since 2025-08-13 + */ +@RunWith(MockitoJUnitRunner.class) +public class ApolloDynamicConfigServiceTest { + public static final long SLEEP_TIME_MILLIS = 1000L; + public final DynamicConfig dynamicConfig = new DynamicConfig(); + public final ServiceMeta serviceMeta = new ServiceMeta(); + private static final String APP_ID = "test-app"; + private static final String NAMESPACE = "application"; + private static boolean createAppNamespaceCalledFlag = false; + + public MockedStatic dynamicConfigMockedStatic; + public MockedStatic serviceManagerMockedStatic; + public MockedStatic configServiceMockedStatic; + public ApolloDynamicConfigService apolloDynamicConfigService; + public MockedStatic apolloApiClientMockedStatic; + + @Mock + private ApolloOpenApiClient openApiClient; + + @Mock + private Config config; + + // mock data structure + private final Map configStore = new HashMap<>(); + private final Set activeListeners = new HashSet<>(); + private final Map stagedChanges = new HashMap<>(); + public ApolloOpenApiClient.ApolloOpenApiClientBuilder mockedBuilder; + + @Before + public void setUp() throws Exception { + dynamicConfig.setEnableAuth(false); + dynamicConfig.setServerAddress("http://127.0.0.1:8080"); + dynamicConfig.setTimeoutValue(30000); + dynamicConfig.setUserName("apollo"); + dynamicConfig.setDefaultGroup("application"); + dynamicConfig.setPrivateKey("c8aca3c749d84e759fd5872057aec199"); + + serviceMeta.setApplication("apptest"); + serviceMeta.setEnvironment("DEV"); + Map map = new HashMap<>(); + map.put("cluster", "default"); + map.put("adminUrl", "http://localhost:8070"); + map.put("token", "00f117c5d4b4c8719c84dbf03182261b68528d61bf8cf6014a6d21ed71717a3e"); + map.put("isDoubleCheck", Boolean.toString(false)); + serviceMeta.setParameters(map); + + dynamicConfigMockedStatic = mockStatic(ConfigManager.class); + dynamicConfigMockedStatic.when(() -> ConfigManager.getConfig(DynamicConfig.class)) + .thenReturn(dynamicConfig); + dynamicConfigMockedStatic.when(() -> ConfigManager.getConfig(ServiceMeta.class)) + .thenReturn(serviceMeta); + + configStore.clear(); + stagedChanges.clear(); + activeListeners.clear(); + + configServiceMockedStatic = Mockito.mockStatic(ConfigService.class); + configServiceMockedStatic.when(() -> ConfigService.getConfig(anyString())) + .thenReturn(config); + + when(config.getProperty(anyString(), anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0); + String defaultValue = invocation.getArgument(1); + return configStore.getOrDefault(key, defaultValue); + }); + + doAnswer(invocation -> { + ConfigChangeListener listener = invocation.getArgument(0); + activeListeners.add(listener); + return null; + }).when(config).addChangeListener(any(ConfigChangeListener.class), anySet()); + + lenient().doAnswer(invocation -> { + ConfigChangeListener listener = invocation.getArgument(0); + activeListeners.add(listener); + return null; + }).when(config).addChangeListener(any(ConfigChangeListener.class)); + + when(config.removeChangeListener(any(ConfigChangeListener.class))).thenAnswer(invocation -> { + ConfigChangeListener listenerToRemove = invocation.getArgument(0); + return activeListeners.remove(listenerToRemove); + }); + + lenient().doAnswer(invocation -> { + OpenItemDTO item = invocation.getArgument(4); + String key = item.getKey(); + String newValue = item.getValue(); + String oldValue = configStore.get(key); + configStore.put(key, newValue); + ConfigChange configChange; + if (oldValue == null || oldValue.isEmpty()) + configChange = new ConfigChange(APP_ID, NAMESPACE, key, oldValue, newValue, PropertyChangeType.ADDED); + else + configChange = new ConfigChange(APP_ID, NAMESPACE, key, oldValue, newValue, PropertyChangeType.MODIFIED); + stagedChanges.put(key, configChange); + return null; + }).when(openApiClient).createOrUpdateItem(anyString(), anyString(), anyString(), anyString(), any(OpenItemDTO.class)); + + doAnswer(invocation -> { + String key = invocation.getArgument(4); + + String oldValue = configStore.get(key); + configStore.remove(key); + + if (oldValue != null) { + ConfigChange configChange = new ConfigChange(APP_ID, NAMESPACE, key, oldValue, null, PropertyChangeType.DELETED); + stagedChanges.put(key, configChange); + } + return null; + }).when(openApiClient).removeItem(anyString(), anyString(), anyString(), anyString(), anyString(), anyString()); + + doAnswer(invocation -> { + if (!stagedChanges.isEmpty() && !activeListeners.isEmpty()) { + ConfigChangeEvent changeEvent = new ConfigChangeEvent(APP_ID, NAMESPACE, new HashMap<>(stagedChanges)); + for (ConfigChangeListener listener : activeListeners) { + listener.onChange(changeEvent); + } + } + stagedChanges.clear(); + return null; + }).when(openApiClient).publishNamespace(anyString(), anyString(), anyString(), anyString(), any()); + + when(openApiClient.getNamespace(anyString(), anyString(), anyString(), anyString(), anyBoolean())).thenAnswer(invocation -> { + if (!createAppNamespaceCalledFlag) { + ApolloOpenApiException cause = new ApolloOpenApiException( + HttpURLConnection.HTTP_NOT_FOUND, + "Not Found", + "Mocked: Namespace not found" + ); + throw new RuntimeException(cause); + } + String appId = invocation.getArgument(0); + String cluster = invocation.getArgument(2); + String namespace = invocation.getArgument(3); + + OpenNamespaceDTO namespaceDTO = new OpenNamespaceDTO(); + namespaceDTO.setAppId(appId); + namespaceDTO.setClusterName(cluster); + namespaceDTO.setNamespaceName(namespace); + + List items = new ArrayList<>(); + for (Map.Entry entry : configStore.entrySet()) { + OpenItemDTO itemDTO = new OpenItemDTO(); + itemDTO.setKey(entry.getKey()); + itemDTO.setValue(entry.getValue()); + itemDTO.setComment("mocked comment"); + items.add(itemDTO); + } + items.sort(Comparator.comparing(OpenItemDTO::getKey)); + namespaceDTO.setItems(items); + return namespaceDTO; + }); + when(openApiClient.getNamespace(anyString(), anyString(), anyString(), anyString())).thenAnswer(invocation -> { + if (!createAppNamespaceCalledFlag) { + ApolloOpenApiException cause = new ApolloOpenApiException( + HttpURLConnection.HTTP_NOT_FOUND, + "Not Found", + "Mocked: Namespace not found" + ); + throw new RuntimeException(cause); + } + String appId = invocation.getArgument(0); + String cluster = invocation.getArgument(2); + String namespace = invocation.getArgument(3); + + OpenNamespaceDTO namespaceDTO = new OpenNamespaceDTO(); + namespaceDTO.setAppId(appId); + namespaceDTO.setClusterName(cluster); + namespaceDTO.setNamespaceName(namespace); + + List items = new ArrayList<>(); + for (Map.Entry entry : configStore.entrySet()) { + OpenItemDTO itemDTO = new OpenItemDTO(); + itemDTO.setKey(entry.getKey()); + itemDTO.setValue(entry.getValue()); + itemDTO.setComment("mocked comment"); + items.add(itemDTO); + } + namespaceDTO.setItems(items); + return namespaceDTO; + }); + when(openApiClient.createAppNamespace(any(OpenAppNamespaceDTO.class))).thenAnswer(invocation -> { + createAppNamespaceCalledFlag = true; + OpenAppNamespaceDTO inputDto = invocation.getArgument(0); + return inputDto; + }); + // mock build Apollo open api client + apolloApiClientMockedStatic = Mockito.mockStatic(ApolloOpenApiClient.class); + mockedBuilder = mock(ApolloOpenApiClient.ApolloOpenApiClientBuilder.class); + apolloApiClientMockedStatic.when(ApolloOpenApiClient::newBuilder).thenReturn(mockedBuilder); + lenient().when(mockedBuilder.withPortalUrl(anyString())).thenReturn(mockedBuilder); + lenient().when(mockedBuilder.withToken(anyString())).thenReturn(mockedBuilder); + lenient().when(mockedBuilder.withConnectTimeout(anyInt())).thenReturn(mockedBuilder); + lenient().when(mockedBuilder.withReadTimeout(anyInt())).thenReturn(mockedBuilder); + when(mockedBuilder.build()).thenReturn(this.openApiClient); + apolloDynamicConfigService = new ApolloDynamicConfigService(); + apolloDynamicConfigService.start(); + serviceManagerMockedStatic = mockStatic(ServiceManager.class); + serviceManagerMockedStatic.when(() -> ServiceManager.getService(ApolloDynamicConfigService.class)) + .thenReturn(apolloDynamicConfigService); + } + + @After + public void stop() { + if (apolloDynamicConfigService != null) { + apolloDynamicConfigService.stop(); + } + if (dynamicConfigMockedStatic != null) { + dynamicConfigMockedStatic.close(); + } + if (serviceManagerMockedStatic != null) { + serviceManagerMockedStatic.close(); + } + if (configServiceMockedStatic != null) { + configServiceMockedStatic.close(); + } + if (apolloApiClientMockedStatic != null) { + apolloApiClientMockedStatic.close(); + } + } + + @Test + public void testApolloDynamicConfigService() throws Exception { + try { + // Config class tests + ApolloProviderManager apolloProviderManager = new ApolloProviderManager(); + apolloProviderManager.initialize(); + Assert.assertEquals(serviceMeta.getApplication(), apolloProviderManager.getProperty("app.id", "")); + Assert.assertEquals(serviceMeta.getEnvironment(), apolloProviderManager.getProperty("env", "")); + Assert.assertEquals(serviceMeta.getParameters().get("cluster"), apolloProviderManager.getProperty("apollo.cluster", "")); + Assert.assertEquals(dynamicConfig.getPrivateKey(), apolloProviderManager.getProperty("apollo.access-key.secret", "")); + ApolloMetaServerProvider apolloMetaServerProvider = new ApolloMetaServerProvider(); + Assert.assertEquals(dynamicConfig.getServerAddress(), apolloMetaServerProvider.getMetaServerAddress(Env.DEV)); + ApolloClient apolloClientSpy = Mockito.spy(new ApolloClient()); + doReturn(HttpURLConnection.HTTP_OK).when(apolloClientSpy).getResponse(any(HttpURLConnection.class)); + Assert.assertTrue(apolloClientSpy.isConnect()); + doReturn(HttpURLConnection.HTTP_BAD_GATEWAY).when(apolloClientSpy).getResponse(any(HttpURLConnection.class)); + Assert.assertFalse(apolloClientSpy.isConnect()); + + // Test publish and get configuration: valid group name + Assert.assertTrue( + apolloDynamicConfigService.doPublishConfig("testTrueSingleConfigKey", + "test.True_Single_Config-Group", + "testTrueSingleConfigContent")); + apolloDynamicConfigService.doPublishConfig("testTrueSingleConfigKey2", + "test.True_Single_Config-Group", + "testTrueSingleConfigContent2"); + + Assert.assertEquals("testTrueSingleConfigContent", apolloDynamicConfigService + .doGetConfig("testTrueSingleConfigKey", "test.True_Single_Config-Group").orElse("")); + + // Test publish and get configuration: invalid group name + Assert.assertFalse( + apolloDynamicConfigService.doPublishConfig("testErrorSingleConfigKey", + "test+++Error&Single" + ":Config-Group", "testErrorSingleConfigContent")); + Thread.sleep(SLEEP_TIME_MILLIS); + Assert.assertEquals(Optional.empty(), apolloDynamicConfigService.doGetConfig( + "testErrorSingleConfigKey", "test+++.Error_Single:Config-Group")); + + List list = new ArrayList<>(); + list.add("testTrueSingleConfigKey"); + list.add("testTrueSingleConfigKey2"); + Assert.assertEquals(list.toString(), + apolloDynamicConfigService.doListKeysFromGroup("test.True_Single_Config-Group").toString()); + + // Test remove configuration + Assert.assertTrue(apolloDynamicConfigService.doRemoveConfig("testTrueSingleConfigKey", + "test.True_Single_Config-Group")); + apolloDynamicConfigService.doRemoveConfig("testTrueSingleConfigKey2", "test.True_Single_Config-Group"); + Thread.sleep(SLEEP_TIME_MILLIS); + Assert.assertEquals("", + apolloDynamicConfigService.doGetConfig("testTrueSingleConfigKey", + "test.True_Single_Config-Group").orElse("")); + + // Test listener addition + ApolloTestListener testListener = new ApolloTestListener(); + Assert.assertTrue( + apolloDynamicConfigService.doPublishConfig("testSingleListenerKey", "testSingleListenerGroup", + "testSingleListenerContent")); + Assert.assertTrue( + apolloDynamicConfigService.doAddConfigListener("testSingleListenerKey", "testSingleListenerGroup", + testListener)); + Thread.sleep(SLEEP_TIME_MILLIS); + Assert.assertTrue( + apolloDynamicConfigService.doPublishConfig("testSingleListenerKey", "testSingleListenerGroup", + "testSingleListenerContent-3")); + + checkChangeTrue(testListener, "testSingleListenerContent-3"); + + // Test listener removal + Assert.assertTrue(apolloDynamicConfigService.doRemoveConfigListener("testSingleListenerKey", + "testSingleListenerGroup")); + Assert.assertTrue( + apolloDynamicConfigService.doPublishConfig("testSingleListenerKey", "testSingleListenerGroup", + "testSingleListenerContent-3")); + + checkChangeFalse(testListener); + Assert.assertTrue( + apolloDynamicConfigService.doRemoveConfig("testSingleListenerKey", "testSingleListenerGroup")); + + // Test the addition of group listeners and get all keys in the group + ApolloTestListener testListenerGroup = new ApolloTestListener(); + Assert.assertTrue( + apolloDynamicConfigService.doPublishConfig("testGroupListenerKey-1", "testGroupListenerGroup", + "testGroupListenerContent-1")); + Assert.assertTrue( + apolloDynamicConfigService.doPublishConfig("testGroupListenerKey-2", "testGroupListenerGroup", + "testGroupListenerContent-2")); + Assert.assertTrue(apolloDynamicConfigService.doAddGroupListener("testGroupListenerGroup", + testListenerGroup)); + Thread.sleep(SLEEP_TIME_MILLIS); + Assert.assertTrue( + apolloDynamicConfigService.doPublishConfig("testGroupListenerKey-2", "testGroupListenerGroup", + "testGroupListenerContent-2-2")); + Assert.assertEquals(2, apolloDynamicConfigService.doListKeysFromGroup("testGroupListenerGroup").size()); + + checkChangeTrue(testListenerGroup, "testGroupListenerContent-2-2"); + + // Test removal of group listeners + Assert.assertTrue(apolloDynamicConfigService.doRemoveGroupListener("testGroupListenerGroup")); + Assert.assertTrue( + apolloDynamicConfigService.doPublishConfig("testGroupListenerKey-3", "testGroupListenerGroup", + "testGroupListenerKey-3")); + + checkChangeFalse(testListenerGroup); + Assert.assertTrue( + apolloDynamicConfigService.doRemoveConfig("testGroupListenerKey-1", "testGroupListenerGroup")); + Assert.assertTrue( + apolloDynamicConfigService.doRemoveConfig("testGroupListenerKey-2", "testGroupListenerGroup")); + Assert.assertTrue( + apolloDynamicConfigService.doRemoveConfig("testGroupListenerKey-3", "testGroupListenerGroup")); + } finally { + apolloDynamicConfigService.doRemoveConfig("testTrueSingleConfigKey", "test.True_Single:Config-Group"); + apolloDynamicConfigService.doRemoveConfig("testSingleListenerKey", "testSingleListenerGroup"); + apolloDynamicConfigService.doRemoveConfig("testGroupListenerKey-1", "testGroupListenerGroup"); + apolloDynamicConfigService.doRemoveConfig("testGroupListenerKey-2", "testGroupListenerGroup"); + apolloDynamicConfigService.doRemoveConfig("testGroupListenerKey-3", "testGroupListenerGroup"); + } + } + + public void checkChangeTrue(ApolloTestListener testListener, String predictContent) throws InterruptedException { + Thread.sleep(SLEEP_TIME_MILLIS); + Assert.assertTrue(testListener.isChange()); + Assert.assertEquals(predictContent, testListener.getContent()); + testListener.setChange(false); + } + + public void checkChangeFalse(ApolloTestListener testListener) throws InterruptedException { + Thread.sleep(SLEEP_TIME_MILLIS); + Assert.assertFalse(testListener.isChange()); + testListener.setChange(false); + } +} + +class ApolloTestListener implements DynamicConfigListener { + /** + * Listening success flag + */ + private boolean isChange = false; + + /** + * Listened configuration content + */ + private String content; + + @Override + public void process(DynamicConfigEvent event) { + setContent(event.getContent()); + setChange(true); + } + + public boolean isChange() { + return isChange; + } + + public void setChange(boolean change) { + isChange = change; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } +} diff --git a/sermant-backend/src/main/java/io/sermant/backend/service/ConfigService.java b/sermant-backend/src/main/java/io/sermant/backend/service/ConfigService.java index 7ec8025fcd..825effff52 100644 --- a/sermant-backend/src/main/java/io/sermant/backend/service/ConfigService.java +++ b/sermant-backend/src/main/java/io/sermant/backend/service/ConfigService.java @@ -30,6 +30,8 @@ import io.sermant.backend.entity.template.PageTemplateInfo; import io.sermant.backend.util.AesUtil; import io.sermant.implement.service.dynamicconfig.ConfigClient; +import io.sermant.implement.service.dynamicconfig.apollo.ApolloClient; +import io.sermant.implement.service.dynamicconfig.apollo.ApolloUtils; import io.sermant.implement.service.dynamicconfig.kie.client.ClientUrlManager; import io.sermant.implement.service.dynamicconfig.kie.client.kie.KieClient; import io.sermant.implement.service.dynamicconfig.nacos.NacosClient; @@ -138,6 +140,8 @@ private List getConfigInfos(ConfigInfo request, PageTemplateInfo tem Set configInfoSet = new HashSet<>(); groupRules.forEach(groupRule -> { String groupPattern = client instanceof NacosClient + ? rebuildGroup(replaceVariableWithWildcard(groupRule, CommonConst.QUERY_WILDCARD)) + : client instanceof ApolloClient ? rebuildGroup(replaceVariableWithWildcard(groupRule, CommonConst.QUERY_WILDCARD)) : replaceVariableWithWildcard(groupRule, CommonConst.PATTERN_WILDCARD); Map> configMap = client.getConfigList(StringUtils.EMPTY, groupPattern, @@ -354,6 +358,9 @@ private String rebuildGroup(String group) { if (configClient instanceof NacosClient) { return NacosUtils.rebuildGroup(group); } + if (configClient instanceof ApolloClient) { + return ApolloUtils.rebuildGroup(group); + } return group; } @@ -367,6 +374,9 @@ public String convertGroup(String group) { if (configClient instanceof NacosClient) { return NacosUtils.convertGroup(group); } + if (configClient instanceof ApolloClient) { + return ApolloUtils.convertGroup(group); + } return group; }