From 5cdec9b9825a6a892d8701c9855a2a05e30b5d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Wed, 27 May 2026 12:46:12 +0200 Subject: [PATCH 01/12] Add support for YAML hot deployment and configuration reloading --- .../predic8/membrane/core/cli/RouterCLI.java | 45 +------ .../membrane/core/router/DefaultRouter.java | 101 +++++++++++++- .../core/router/YamlRouterBootstrap.java | 94 +++++++++++++ .../router/hotdeploy/DefaultHotDeployer.java | 51 +++++-- .../hotdeploy/YamlHotDeploymentThread.java | 124 ++++++++++++++++++ 5 files changed, 356 insertions(+), 59 deletions(-) create mode 100644 core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java create mode 100644 core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java diff --git a/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java b/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java index 8bdd695b47..703adb81a3 100644 --- a/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java +++ b/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java @@ -15,20 +15,17 @@ package com.predic8.membrane.core.cli; import com.fasterxml.jackson.core.JsonProcessingException; -import com.predic8.membrane.annot.beanregistry.BeanDefinitions; -import com.predic8.membrane.annot.beanregistry.BeanRegistryImplementation; import com.predic8.membrane.annot.yaml.ConfigurationParsingException; -import com.predic8.membrane.core.config.spring.GrammarAutoGenerated; import com.predic8.membrane.core.config.spring.TrackingFileSystemXmlApplicationContext; import com.predic8.membrane.core.exceptions.SpringConfigurationErrorHandler; import com.predic8.membrane.core.openapi.serviceproxy.APIProxy; import com.predic8.membrane.core.openapi.serviceproxy.OpenAPISpec; import com.predic8.membrane.core.resolver.ResolverMap; import com.predic8.membrane.core.resolver.ResourceRetrievalException; -import com.predic8.membrane.core.router.Configuration; import com.predic8.membrane.core.router.DefaultRouter; import com.predic8.membrane.core.router.Router; import com.predic8.membrane.core.router.RouterXmlBootstrap; +import com.predic8.membrane.core.router.YamlRouterBootstrap; import org.apache.commons.cli.ParseException; import org.apache.commons.codec.binary.Hex; import org.jetbrains.annotations.NotNull; @@ -39,11 +36,7 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; -import java.net.URI; -import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; -import java.nio.file.InvalidPathException; -import java.nio.file.Path; import java.security.SecureRandom; import java.util.Arrays; import java.util.List; @@ -62,7 +55,6 @@ import static com.predic8.membrane.core.proxies.ApiInfo.logInfosAboutStartedProxies; import static com.predic8.membrane.core.util.ExceptionUtil.concatMessageAndCauseMessages; import static com.predic8.membrane.core.util.OSUtil.fixBackslashes; -import static com.predic8.membrane.core.util.OSUtil.isWindowsAbsolutePath; import static com.predic8.membrane.core.util.URIUtil.pathFromFileURI; import static com.predic8.membrane.core.util.text.TerminalColors.*; import static java.lang.Integer.parseInt; @@ -238,46 +230,13 @@ private static Router initRouterByYAML(MembraneCommandLine commandLine, String o static Router initRouterByYAML(String location) throws Exception { var router = new DefaultRouter(); - var grammar = new GrammarAutoGenerated(); - var reg = new BeanRegistryImplementation(grammar); - - router.setRegistry(reg); - reg.register("router", router); - - BeanDefinitions beanDefinitions = new BeanDefinitions(reg.parseYamlBeanDefinitions(router.getResolverMap().resolve(location), grammar, getRootSourceFile(location))); - beanDefinitions.ensureSingleGlobalDefinition(); - beanDefinitions.getConfigDefinition() - .ifPresent(configBd -> router.applyConfiguration((Configuration) reg.resolve(configBd.getName()))); - - reg.finishStaticConfiguration(); - reg.start(); - - // Must be set after reading the YAML. Otherwise, it will be overwritten - router.getConfiguration().setBaseLocation(location); - + YamlRouterBootstrap.loadIntoRouter(router, location); router.start(); logInfosAboutStartedProxies(router.getRuleManager()); logStartupMessage(); return router; } - private static Path getRootSourceFile(String location) { - if (!isWindowsAbsolutePath(location)) { - try { - URI uri = new URI(location); - String scheme = uri.getScheme(); - if (scheme != null && !"file".equalsIgnoreCase(scheme)) - return null; - } catch (URISyntaxException ignored) {} - } - - try { - return Path.of(pathFromFileURI(location)); - } catch (InvalidPathException ignored) { - return null; - } - } - private static @NotNull APIProxy getApiProxy(MembraneCommandLine commandLine) throws IOException { APIProxy api = new APIProxy(); api.setPort(commandLine.getCommand().isOptionSet("p") ? diff --git a/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java b/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java index a20de2ce1a..8164c2fc04 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java +++ b/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java @@ -38,7 +38,6 @@ import com.predic8.membrane.core.resolver.ResolverMap; import com.predic8.membrane.core.router.hotdeploy.DefaultHotDeployer; import com.predic8.membrane.core.router.hotdeploy.HotDeployer; -import com.predic8.membrane.core.router.hotdeploy.NullHotDeployer; import com.predic8.membrane.core.transport.PortOccupiedException; import com.predic8.membrane.core.transport.Transport; import com.predic8.membrane.core.transport.http.HttpClient; @@ -56,8 +55,12 @@ import org.springframework.context.support.AbstractRefreshableApplicationContext; import javax.annotation.concurrent.GuardedBy; +import java.io.File; import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; import java.util.Collection; +import java.util.List; import static com.predic8.membrane.core.proxies.RuleManager.RuleDefinitionSource.MANUAL; import static com.predic8.membrane.core.util.DLPUtil.displayTraceWarning; @@ -111,6 +114,9 @@ public class DefaultRouter extends AbstractRouter implements ApplicationContextA @GuardedBy("lock") private boolean initialized; + @GuardedBy("lock") + private boolean reloading; + /** * HotDeployer for changes on the configuration file. * Not synchronized, since only modified during initialization @@ -119,6 +125,9 @@ public class DefaultRouter extends AbstractRouter implements ApplicationContextA private RuleReinitializer reinitializer; + private String yamlConfigurationLocation; + private List yamlTrackedFiles = List.of(); + public DefaultRouter() { log.debug("Creating new router."); mainComponents = new DefaultMainComponents(this); @@ -299,12 +308,16 @@ public void stop() { getRegistry().getBean(KubernetesWatcher.class).ifPresent(KubernetesWatcher::stop); hotDeployer.stop(); - if (mainComponents.getTransport() != null) + if (reinitializer != null) + reinitializer.stop(); + if (mainComponents.getTransport() != null) mainComponents.getTransport().closeAll(); mainComponents.getTimerManager().shutdown(); + closeRegistryIfSupported(); synchronized (lock) { running = false; + reloading = false; lock.notifyAll(); } } @@ -347,7 +360,7 @@ public String getId() { */ public void waitFor() { synchronized (lock) { - while (running) { + while (running || reloading) { try { lock.wait(); } catch (InterruptedException ignored) { @@ -430,7 +443,7 @@ public BeanRegistry getRegistry() { } public void applyConfiguration(Configuration configuration) { - hotDeployer = configuration.isHotDeploy() ? new DefaultHotDeployer() : new NullHotDeployer(); + hotDeployer.setEnabled(configuration.isHotDeploy()); this.configuration = configuration; } @@ -454,6 +467,84 @@ public RuleReinitializer getReinitializer() { return reinitializer; } + public synchronized void setYamlConfiguration(String yamlConfigurationLocation, List trackedFiles) { + this.yamlConfigurationLocation = yamlConfigurationLocation; + this.yamlTrackedFiles = trackedFiles.stream() + .map(Path::toFile) + .toList(); + } + + public synchronized String getYamlConfigurationLocation() { + return yamlConfigurationLocation; + } + + public synchronized List getYamlTrackedFiles() { + return new ArrayList<>(yamlTrackedFiles); + } + + public boolean reloadYamlConfiguration() { + String location; + synchronized (lock) { + if (reloading) { + return false; + } + reloading = true; + location = yamlConfigurationLocation; + lock.notifyAll(); + } + + try { + if (location == null || location.isBlank()) { + throw new IllegalStateException("No YAML configuration location is known."); + } + + log.info("Reloading YAML configuration from {}.", location); + shutdownForYamlReload(); + resetForYamlReload(); + YamlRouterBootstrap.loadIntoRouter(this, location); + start(); + log.info("YAML configuration reloaded successfully."); + return true; + } catch (Exception e) { + log.error("Could not reload YAML configuration.", e); + return false; + } finally { + synchronized (lock) { + reloading = false; + lock.notifyAll(); + } + } + } + + private void shutdownForYamlReload() { + getRegistry().getBean(KubernetesWatcher.class).ifPresent(KubernetesWatcher::stop); + + if (reinitializer != null) + reinitializer.stop(); + if (mainComponents.getTransport() != null) + mainComponents.getTransport().closeAll(); + mainComponents.getTimerManager().shutdown(); + closeRegistryIfSupported(); + + synchronized (lock) { + running = false; + lock.notifyAll(); + } + } + + private void resetForYamlReload() { + mainComponents = new DefaultMainComponents(this); + configuration = new Configuration(); + initialized = false; + reinitializer = null; + } + + private void closeRegistryIfSupported() { + if (getRegistry() instanceof BeanRegistryImplementation beanRegistry) { + beanRegistry.close(); + } + } + private static void handleOpenAPIParsingException(OpenAPIParsingException e) { System.err.printf(""" ================================================================================================ @@ -483,4 +574,4 @@ private static void handleDuplicateOpenAPIPaths(DuplicatePathException e) { %n""", e.getPath()); throw new ExitException(); } -} \ No newline at end of file +} diff --git a/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java b/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java new file mode 100644 index 0000000000..8f6ddd34d2 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java @@ -0,0 +1,94 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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 com.predic8.membrane.core.router; + +import com.predic8.membrane.annot.beanregistry.BeanDefinition; +import com.predic8.membrane.annot.beanregistry.BeanDefinitions; +import com.predic8.membrane.annot.beanregistry.BeanRegistryImplementation; +import com.predic8.membrane.core.config.spring.GrammarAutoGenerated; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.List; + +import static com.predic8.membrane.core.util.OSUtil.isWindowsAbsolutePath; +import static com.predic8.membrane.core.util.URIUtil.pathFromFileURI; + +public final class YamlRouterBootstrap { + + private YamlRouterBootstrap() { + } + + public static void loadIntoRouter(DefaultRouter router, String location) throws Exception { + var grammar = new GrammarAutoGenerated(); + var registry = new BeanRegistryImplementation(grammar); + + router.setRegistry(registry); + registry.register("router", router); + + List definitions = registry.parseYamlBeanDefinitions( + router.getResolverMap().resolve(location), + grammar, + getRootSourceFile(location) + ); + + BeanDefinitions beanDefinitions = new BeanDefinitions(definitions); + beanDefinitions.ensureSingleGlobalDefinition(); + beanDefinitions.getConfigDefinition().ifPresent(configBd -> router.applyConfiguration((Configuration) registry.resolve(configBd.getName()))); + + router.setYamlConfiguration(location, collectTrackedFiles(location, definitions)); + registry.finishStaticConfiguration(); + router.getConfiguration().setBaseLocation(location); + } + + private static List collectTrackedFiles(String location, List definitions) { + LinkedHashSet trackedFiles = new LinkedHashSet<>(); + + Path rootSourceFile = getRootSourceFile(location); + if (rootSourceFile != null) { + trackedFiles.add(rootSourceFile.toAbsolutePath().normalize()); + } + + for (BeanDefinition definition : definitions) { + if (definition.getSourceMetadata() == null || definition.getSourceMetadata().sourceFile() == null) { + continue; + } + trackedFiles.add(definition.getSourceMetadata().sourceFile().toAbsolutePath().normalize()); + } + + return List.copyOf(trackedFiles); + } + + private static Path getRootSourceFile(String location) { + if (!isWindowsAbsolutePath(location)) { + try { + URI uri = new URI(location); + String scheme = uri.getScheme(); + if (scheme != null && !"file".equalsIgnoreCase(scheme)) + return null; + } catch (URISyntaxException ignored) { + } + } + + try { + return Path.of(pathFromFileURI(location)); + } catch (InvalidPathException ignored) { + return null; + } + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java index 591214c5ee..8610c92778 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java @@ -26,11 +26,12 @@ public class DefaultHotDeployer implements HotDeployer { private static final Logger log = LoggerFactory.getLogger(DefaultHotDeployer.class.getName()); @GuardedBy("lock") - private HotDeploymentThread hdt; + private Thread hdt; private DefaultRouter router; private final Object lock = new Object(); + private boolean enabled = true; @Override public void start(@NotNull DefaultRouter defaultRouter) { @@ -41,8 +42,19 @@ public void start(@NotNull DefaultRouter defaultRouter) { private void startInternal() { // Prevent multiple threads from starting hot deployment at the same time. synchronized (lock) { + if (!enabled) { + return; + } + if (hdt != null) { - log.warn("Hot deployment already started."); + if (!hdt.isAlive()) { + hdt = null; + } else { + return; + } + } + + if (router == null) { return; } @@ -54,13 +66,20 @@ private void startInternal() { """); return; } - hdt = new HotDeploymentThread(router.getRef()); - hdt.setFiles(tac.getFiles()); + HotDeploymentThread hotDeploymentThread = new HotDeploymentThread(router.getRef()); + hotDeploymentThread.setFiles(tac.getFiles()); + hdt = hotDeploymentThread; + hotDeploymentThread.start(); + return; + } + + if (router.getYamlConfigurationLocation() != null && !router.getYamlTrackedFiles().isEmpty()) { + hdt = new YamlHotDeploymentThread(router, this); hdt.start(); return; } - log.debug("Hot deployment is not yet supported for the YAML configuration."); + log.debug("Hot deployment skipped because no local YAML files are known."); } } @@ -70,22 +89,32 @@ public void stop() { if (hdt == null) return; - router.getReinitializer().stop(); - hdt.stopASAP(); + if (router != null && router.getReinitializer() != null) { + router.getReinitializer().stop(); + } + + if (hdt instanceof HotDeploymentThread hotDeploymentThread) { + hotDeploymentThread.stopASAP(); + } else if (hdt instanceof YamlHotDeploymentThread yamlHotDeploymentThread) { + yamlHotDeploymentThread.stopASAP(); + } else { + hdt.interrupt(); + } hdt = null; } } @Override public void setEnabled(boolean enabled) { - if (enabled && router != null) + this.enabled = enabled; + + if (enabled && router != null) { startInternal(); - else - stop(); + } } @Override public boolean isEnabled() { - return true; + return enabled; } } diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java new file mode 100644 index 0000000000..358d2b27c5 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java @@ -0,0 +1,124 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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 com.predic8.membrane.core.router.hotdeploy; + +import com.predic8.membrane.core.router.DefaultRouter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; + +public class YamlHotDeploymentThread extends Thread { + + private static final Logger log = LoggerFactory.getLogger(YamlHotDeploymentThread.class.getName()); + + private final DefaultRouter router; + private final DefaultHotDeployer hotDeployer; + private final List files = new ArrayList<>(); + + private static class FileInfo { + public String file; + public long lastModified; + } + + public YamlHotDeploymentThread(DefaultRouter router, DefaultHotDeployer hotDeployer) { + super("yaml-hotdeploy"); + this.router = router; + this.hotDeployer = hotDeployer; + refreshTrackedFiles(); + } + + private void refreshTrackedFiles() { + files.clear(); + LinkedHashSet trackedPaths = new LinkedHashSet<>(); + for (File file : router.getYamlTrackedFiles()) { + trackedPaths.add(file.getAbsolutePath()); + File parent = file.getParentFile(); + if (parent != null) { + trackedPaths.add(parent.getAbsolutePath()); + } + } + for (String path : trackedPaths) { + FileInfo fileInfo = new FileInfo(); + fileInfo.file = path; + files.add(fileInfo); + } + updateLastModified(); + } + + private void updateLastModified() { + for (FileInfo fileInfo : files) { + fileInfo.lastModified = getLastModified(fileInfo.file); + } + } + + private static long getLastModified(String file) { + return new File(file).lastModified(); + } + + private boolean configurationChanged() { + for (FileInfo fileInfo : files) { + if (getLastModified(fileInfo.file) != fileInfo.lastModified) { + return true; + } + } + return false; + } + + @Override + public void run() { + log.debug("YAML Hot Deployment Thread started."); + + while (!isInterrupted() && hotDeployer.isEnabled()) { + try { + while (!configurationChanged()) { + if (isInterrupted() || !hotDeployer.isEnabled()) { + log.debug("YAML Hot Deployment Thread interrupted."); + return; + } + + //noinspection BusyWait + sleep(1000); + } + + log.debug("yaml configuration changed."); + + if (!router.reloadYamlConfiguration()) { + break; + } + + if (!hotDeployer.isEnabled()) { + break; + } + + refreshTrackedFiles(); + } catch (InterruptedException e) { + interrupt(); + } catch (Exception e) { + log.error("Could not redeploy YAML configuration.", e); + break; + } + } + + log.debug("YAML Hot Deployment Thread interrupted."); + } + + public void stopASAP() { + interrupt(); + } +} From 6bac0a4208f08f95ddf5d6c7d637d2a87ae70836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Wed, 27 May 2026 12:56:58 +0200 Subject: [PATCH 02/12] Log proxy info and startup details after YAML reload --- .../com/predic8/membrane/core/router/DefaultRouter.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java b/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java index 8164c2fc04..808a97dcd0 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java +++ b/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java @@ -62,8 +62,13 @@ import java.util.Collection; import java.util.List; +import static com.predic8.membrane.annot.Constants.PRODUCT_NAME; +import static com.predic8.membrane.annot.Constants.VERSION; +import static com.predic8.membrane.core.proxies.ApiInfo.logInfosAboutStartedProxies; import static com.predic8.membrane.core.proxies.RuleManager.RuleDefinitionSource.MANUAL; import static com.predic8.membrane.core.util.DLPUtil.displayTraceWarning; +import static com.predic8.membrane.core.util.text.TerminalColors.BRIGHT_CYAN; +import static com.predic8.membrane.core.util.text.TerminalColors.RESET; /* * Responsibilities: @@ -503,6 +508,8 @@ public boolean reloadYamlConfiguration() { resetForYamlReload(); YamlRouterBootstrap.loadIntoRouter(this, location); start(); + logInfosAboutStartedProxies(getRuleManager()); + log.info("{}{} {} up and running!{}", BRIGHT_CYAN(), PRODUCT_NAME, VERSION, RESET()); log.info("YAML configuration reloaded successfully."); return true; } catch (Exception e) { From bcd5b95cd3c60941aeeb6981e3f45d57ddc49157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Wed, 27 May 2026 12:59:46 +0200 Subject: [PATCH 03/12] Ensure thread-safety for YAML configuration methods in DefaultRouter --- .../membrane/core/router/DefaultRouter.java | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java b/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java index 808a97dcd0..dc95da3ce5 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java +++ b/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java @@ -130,7 +130,9 @@ public class DefaultRouter extends AbstractRouter implements ApplicationContextA private RuleReinitializer reinitializer; + @GuardedBy("lock") private String yamlConfigurationLocation; + @GuardedBy("lock") private List yamlTrackedFiles = List.of(); public DefaultRouter() { @@ -472,19 +474,25 @@ public RuleReinitializer getReinitializer() { return reinitializer; } - public synchronized void setYamlConfiguration(String yamlConfigurationLocation, List trackedFiles) { - this.yamlConfigurationLocation = yamlConfigurationLocation; - this.yamlTrackedFiles = trackedFiles.stream() - .map(Path::toFile) - .toList(); + public void setYamlConfiguration(String yamlConfigurationLocation, List trackedFiles) { + synchronized (lock) { + this.yamlConfigurationLocation = yamlConfigurationLocation; + this.yamlTrackedFiles = trackedFiles.stream() + .map(Path::toFile) + .toList(); + } } - public synchronized String getYamlConfigurationLocation() { - return yamlConfigurationLocation; + public String getYamlConfigurationLocation() { + synchronized (lock) { + return yamlConfigurationLocation; + } } - public synchronized List getYamlTrackedFiles() { - return new ArrayList<>(yamlTrackedFiles); + public List getYamlTrackedFiles() { + synchronized (lock) { + return new ArrayList<>(yamlTrackedFiles); + } } public boolean reloadYamlConfiguration() { From a538e0dd94d68755aee012a1bd8b0affe1452b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Wed, 27 May 2026 14:21:43 +0200 Subject: [PATCH 04/12] Add robust error handling and recovery for YAML configuration reloads --- .../membrane/core/router/DefaultRouter.java | 91 ++++++++++++++++++- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java b/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java index dc95da3ce5..47f5f51198 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java +++ b/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java @@ -497,6 +497,7 @@ public List getYamlTrackedFiles() { public boolean reloadYamlConfiguration() { String location; + YamlRuntimeState previousState = null; synchronized (lock) { if (reloading) { return false; @@ -512,17 +513,24 @@ public boolean reloadYamlConfiguration() { } log.info("Reloading YAML configuration from {}.", location); + validateYamlConfiguration(location); + previousState = captureYamlRuntimeState(); shutdownForYamlReload(); resetForYamlReload(); YamlRouterBootstrap.loadIntoRouter(this, location); start(); + disposeYamlRuntime(previousState); logInfosAboutStartedProxies(getRuleManager()); log.info("{}{} {} up and running!{}", BRIGHT_CYAN(), PRODUCT_NAME, VERSION, RESET()); log.info("YAML configuration reloaded successfully."); return true; } catch (Exception e) { log.error("Could not reload YAML configuration.", e); - return false; + if (previousState != null) { + return recoverPreviousYamlRuntime(previousState, e); + } + log.info("Keeping the previous YAML runtime because reload validation failed before shutdown."); + return true; } finally { synchronized (lock) { reloading = false; @@ -537,9 +545,7 @@ private void shutdownForYamlReload() { if (reinitializer != null) reinitializer.stop(); if (mainComponents.getTransport() != null) - mainComponents.getTransport().closeAll(); - mainComponents.getTimerManager().shutdown(); - closeRegistryIfSupported(); + mainComponents.getTransport().closeAll(false); synchronized (lock) { running = false; @@ -554,12 +560,87 @@ private void resetForYamlReload() { reinitializer = null; } + private void validateYamlConfiguration(String location) throws Exception { + DefaultRouter candidate = new DefaultRouter(); + try { + YamlRouterBootstrap.loadIntoRouter(candidate, location); + } finally { + candidate.getTimerManager().shutdown(); + candidate.closeRegistryIfSupported(); + } + } + + private YamlRuntimeState captureYamlRuntimeState() { + synchronized (lock) { + return new YamlRuntimeState( + mainComponents, + configuration, + initialized, + reinitializer, + yamlConfigurationLocation, + new ArrayList<>(yamlTrackedFiles) + ); + } + } + + private boolean recoverPreviousYamlRuntime(YamlRuntimeState previousState, Exception originalError) { + log.info("Attempting to recover the previous YAML runtime after reload failure."); + + restoreYamlRuntime(previousState); + + try { + start(); + log.info("Recovered the previous YAML runtime after reload failure."); + return true; + } catch (Exception recoveryError) { + log.error("Could not recover the previous YAML runtime after reload failure.", recoveryError); + recoveryError.addSuppressed(originalError); + return false; + } + } + + private void restoreYamlRuntime(YamlRuntimeState previousState) { + synchronized (lock) { + mainComponents = previousState.mainComponents(); + configuration = previousState.configuration(); + initialized = previousState.initialized(); + reinitializer = previousState.reinitializer(); + yamlConfigurationLocation = previousState.yamlConfigurationLocation(); + yamlTrackedFiles = new ArrayList<>(previousState.yamlTrackedFiles()); + } + hotDeployer.setEnabled(previousState.configuration().isHotDeploy()); + } + + private void disposeYamlRuntime(YamlRuntimeState previousState) { + if (previousState == null) + return; + + if (previousState.reinitializer() != null) + previousState.reinitializer().stop(); + previousState.mainComponents().getTimerManager().shutdown(); + closeRegistryIfSupported(previousState.mainComponents().getRegistry()); + } + private void closeRegistryIfSupported() { - if (getRegistry() instanceof BeanRegistryImplementation beanRegistry) { + closeRegistryIfSupported(getRegistry()); + } + + private void closeRegistryIfSupported(BeanRegistry registry) { + if (registry instanceof BeanRegistryImplementation beanRegistry) { beanRegistry.close(); } } + private record YamlRuntimeState( + DefaultMainComponents mainComponents, + Configuration configuration, + boolean initialized, + RuleReinitializer reinitializer, + String yamlConfigurationLocation, + List yamlTrackedFiles + ) { + } + private static void handleOpenAPIParsingException(OpenAPIParsingException e) { System.err.printf(""" ================================================================================================ From 3b998674552b17e4bcec63e3ce2da09a195293bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Wed, 27 May 2026 14:23:38 +0200 Subject: [PATCH 05/12] Ensure thread-safety for resetForYamlReload method in DefaultRouter --- .../predic8/membrane/core/router/DefaultRouter.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java b/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java index 47f5f51198..917557e265 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java +++ b/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java @@ -554,10 +554,12 @@ private void shutdownForYamlReload() { } private void resetForYamlReload() { - mainComponents = new DefaultMainComponents(this); - configuration = new Configuration(); - initialized = false; - reinitializer = null; + synchronized (lock) { + mainComponents = new DefaultMainComponents(this); + configuration = new Configuration(); + initialized = false; + reinitializer = null; + } } private void validateYamlConfiguration(String location) throws Exception { From 9a4b4e19c27541ffcc5be68a5a134ec61720ed1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Wed, 27 May 2026 14:24:48 +0200 Subject: [PATCH 06/12] Ensure thread-safety for enabled flag in DefaultHotDeployer --- .../membrane/core/router/hotdeploy/DefaultHotDeployer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java index 8610c92778..999c5f5cc6 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java @@ -31,7 +31,7 @@ public class DefaultHotDeployer implements HotDeployer { private DefaultRouter router; private final Object lock = new Object(); - private boolean enabled = true; + private volatile boolean enabled = true; @Override public void start(@NotNull DefaultRouter defaultRouter) { From d66b21f5cdbb5f4a7c96667077463297df4c4784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Thu, 28 May 2026 10:17:00 +0200 Subject: [PATCH 07/12] Refactor YAML configuration reloading by introducing ConfigurationReloader interface and enhancing hot deployment logic --- .../generator/IncludeListClassGenerator.java | 5 +- .../predic8/membrane/core/cli/RouterCLI.java | 5 +- .../membrane/core/router/DefaultRouter.java | 154 ++---------------- .../core/router/YamlConfigurationSource.java | 25 +++ .../core/router/YamlRouterBootstrap.java | 8 +- .../hotdeploy/ConfigurationReloader.java | 25 +++ .../router/hotdeploy/DefaultHotDeployer.java | 50 ++++-- .../router/hotdeploy/FileWatchSnapshot.java | 55 +++++++ .../core/router/hotdeploy/HotDeployer.java | 3 +- .../hotdeploy/YamlHotDeploymentThread.java | 72 ++------ .../router/hotdeploy/YamlRouterReloader.java | 108 ++++++++++++ 11 files changed, 295 insertions(+), 215 deletions(-) create mode 100644 core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java create mode 100644 core/src/main/java/com/predic8/membrane/core/router/hotdeploy/ConfigurationReloader.java create mode 100644 core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java create mode 100644 core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java diff --git a/annot/src/main/java/com/predic8/membrane/annot/generator/IncludeListClassGenerator.java b/annot/src/main/java/com/predic8/membrane/annot/generator/IncludeListClassGenerator.java index 6e0602c6fb..d3281c6477 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/generator/IncludeListClassGenerator.java +++ b/annot/src/main/java/com/predic8/membrane/annot/generator/IncludeListClassGenerator.java @@ -44,6 +44,8 @@ protected String getClassImpl() { * Include entries are resolved in the order they are listed. If an entry points to a directory, * all matching *.apis.yaml / *.apis.yml files in that directory are included * in lexicographical order. Includes are resolved recursively and cyclic include chains are rejected. + * Directory includes are not watched for YAML hot deployment; adding or removing files there does not + * trigger a reload automatically. *

* @yaml

                  * include:
@@ -62,7 +64,8 @@ public class IncludeList {
                      * 

*

* Each string is a path to either a YAML file or a directory. Relative paths are resolved - * against the directory of the including file. Absolute paths are also supported. + * against the directory of the including file. Absolute paths are also supported. Directory + * entries are loaded during parsing, but they are not watched for YAML hot deployment. *

*/ @MCChildElement(allowForeign = true) diff --git a/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java b/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java index 23fc17e37e..8ebab075a4 100644 --- a/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java +++ b/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java @@ -25,7 +25,7 @@ import com.predic8.membrane.core.router.DefaultRouter; import com.predic8.membrane.core.router.Router; import com.predic8.membrane.core.router.RouterXmlBootstrap; -import com.predic8.membrane.core.router.YamlRouterBootstrap; +import com.predic8.membrane.core.router.hotdeploy.YamlRouterReloader; import org.apache.commons.cli.ParseException; import org.apache.commons.codec.binary.Hex; import org.jetbrains.annotations.NotNull; @@ -51,6 +51,7 @@ import static com.predic8.membrane.core.openapi.serviceproxy.OpenAPISpec.YesNoOpenAPIOption.YES; import static com.predic8.membrane.core.openapi.util.OpenAPIUtil.isOpenAPIMisplacedError; import static com.predic8.membrane.core.proxies.ApiInfo.logInfosAboutStartedProxies; +import static com.predic8.membrane.core.router.YamlRouterBootstrap.loadIntoRouter; import static com.predic8.membrane.core.util.ExceptionUtil.concatMessageAndCauseMessages; import static com.predic8.membrane.core.util.OSUtil.fixBackslashes; import static com.predic8.membrane.core.util.URIUtil.pathFromFileURI; @@ -228,7 +229,7 @@ private static Router initRouterByYAML(MembraneCommandLine commandLine, String o static Router initRouterByYAML(String location) throws Exception { var router = new DefaultRouter(); - YamlRouterBootstrap.loadIntoRouter(router, location); + router.setConfigurationReloader(new YamlRouterReloader(router, loadIntoRouter(router, location))); router.start(); logInfosAboutStartedProxies(router.getRuleManager()); logStartupMessage(); diff --git a/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java b/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java index 917557e265..2f7a64c18b 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java +++ b/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java @@ -36,6 +36,7 @@ import com.predic8.membrane.core.proxies.RuleManager.RuleDefinitionSource; import com.predic8.membrane.core.proxies.SSLableProxy; import com.predic8.membrane.core.resolver.ResolverMap; +import com.predic8.membrane.core.router.hotdeploy.ConfigurationReloader; import com.predic8.membrane.core.router.hotdeploy.DefaultHotDeployer; import com.predic8.membrane.core.router.hotdeploy.HotDeployer; import com.predic8.membrane.core.transport.PortOccupiedException; @@ -55,20 +56,11 @@ import org.springframework.context.support.AbstractRefreshableApplicationContext; import javax.annotation.concurrent.GuardedBy; -import java.io.File; import java.io.IOException; -import java.nio.file.Path; -import java.util.ArrayList; import java.util.Collection; -import java.util.List; -import static com.predic8.membrane.annot.Constants.PRODUCT_NAME; -import static com.predic8.membrane.annot.Constants.VERSION; -import static com.predic8.membrane.core.proxies.ApiInfo.logInfosAboutStartedProxies; import static com.predic8.membrane.core.proxies.RuleManager.RuleDefinitionSource.MANUAL; import static com.predic8.membrane.core.util.DLPUtil.displayTraceWarning; -import static com.predic8.membrane.core.util.text.TerminalColors.BRIGHT_CYAN; -import static com.predic8.membrane.core.util.text.TerminalColors.RESET; /* * Responsibilities: @@ -130,11 +122,6 @@ public class DefaultRouter extends AbstractRouter implements ApplicationContextA private RuleReinitializer reinitializer; - @GuardedBy("lock") - private String yamlConfigurationLocation; - @GuardedBy("lock") - private List yamlTrackedFiles = List.of(); - public DefaultRouter() { log.debug("Creating new router."); mainComponents = new DefaultMainComponents(this); @@ -474,72 +461,29 @@ public RuleReinitializer getReinitializer() { return reinitializer; } - public void setYamlConfiguration(String yamlConfigurationLocation, List trackedFiles) { - synchronized (lock) { - this.yamlConfigurationLocation = yamlConfigurationLocation; - this.yamlTrackedFiles = trackedFiles.stream() - .map(Path::toFile) - .toList(); - } - } - - public String getYamlConfigurationLocation() { - synchronized (lock) { - return yamlConfigurationLocation; - } - } - - public List getYamlTrackedFiles() { - synchronized (lock) { - return new ArrayList<>(yamlTrackedFiles); - } + public void setConfigurationReloader(ConfigurationReloader configurationReloader) { + hotDeployer.setConfigurationReloader(configurationReloader); } - public boolean reloadYamlConfiguration() { - String location; - YamlRuntimeState previousState = null; + public boolean markReloading() { synchronized (lock) { if (reloading) { return false; } reloading = true; - location = yamlConfigurationLocation; lock.notifyAll(); + return true; } + } - try { - if (location == null || location.isBlank()) { - throw new IllegalStateException("No YAML configuration location is known."); - } - - log.info("Reloading YAML configuration from {}.", location); - validateYamlConfiguration(location); - previousState = captureYamlRuntimeState(); - shutdownForYamlReload(); - resetForYamlReload(); - YamlRouterBootstrap.loadIntoRouter(this, location); - start(); - disposeYamlRuntime(previousState); - logInfosAboutStartedProxies(getRuleManager()); - log.info("{}{} {} up and running!{}", BRIGHT_CYAN(), PRODUCT_NAME, VERSION, RESET()); - log.info("YAML configuration reloaded successfully."); - return true; - } catch (Exception e) { - log.error("Could not reload YAML configuration.", e); - if (previousState != null) { - return recoverPreviousYamlRuntime(previousState, e); - } - log.info("Keeping the previous YAML runtime because reload validation failed before shutdown."); - return true; - } finally { - synchronized (lock) { - reloading = false; - lock.notifyAll(); - } + public void clearReloading() { + synchronized (lock) { + reloading = false; + lock.notifyAll(); } } - private void shutdownForYamlReload() { + public void stopRuntimeForReload() { getRegistry().getBean(KubernetesWatcher.class).ifPresent(KubernetesWatcher::stop); if (reinitializer != null) @@ -553,7 +497,7 @@ private void shutdownForYamlReload() { } } - private void resetForYamlReload() { + public void resetRuntime() { synchronized (lock) { mainComponents = new DefaultMainComponents(this); configuration = new Configuration(); @@ -562,65 +506,11 @@ private void resetForYamlReload() { } } - private void validateYamlConfiguration(String location) throws Exception { - DefaultRouter candidate = new DefaultRouter(); - try { - YamlRouterBootstrap.loadIntoRouter(candidate, location); - } finally { - candidate.getTimerManager().shutdown(); - candidate.closeRegistryIfSupported(); - } - } - - private YamlRuntimeState captureYamlRuntimeState() { - synchronized (lock) { - return new YamlRuntimeState( - mainComponents, - configuration, - initialized, - reinitializer, - yamlConfigurationLocation, - new ArrayList<>(yamlTrackedFiles) - ); - } - } - - private boolean recoverPreviousYamlRuntime(YamlRuntimeState previousState, Exception originalError) { - log.info("Attempting to recover the previous YAML runtime after reload failure."); - - restoreYamlRuntime(previousState); - - try { - start(); - log.info("Recovered the previous YAML runtime after reload failure."); - return true; - } catch (Exception recoveryError) { - log.error("Could not recover the previous YAML runtime after reload failure.", recoveryError); - recoveryError.addSuppressed(originalError); - return false; - } - } - - private void restoreYamlRuntime(YamlRuntimeState previousState) { - synchronized (lock) { - mainComponents = previousState.mainComponents(); - configuration = previousState.configuration(); - initialized = previousState.initialized(); - reinitializer = previousState.reinitializer(); - yamlConfigurationLocation = previousState.yamlConfigurationLocation(); - yamlTrackedFiles = new ArrayList<>(previousState.yamlTrackedFiles()); - } - hotDeployer.setEnabled(previousState.configuration().isHotDeploy()); - } - - private void disposeYamlRuntime(YamlRuntimeState previousState) { - if (previousState == null) - return; - - if (previousState.reinitializer() != null) - previousState.reinitializer().stop(); - previousState.mainComponents().getTimerManager().shutdown(); - closeRegistryIfSupported(previousState.mainComponents().getRegistry()); + public void disposeRuntime() { + if (reinitializer != null) + reinitializer.stop(); + mainComponents.getTimerManager().shutdown(); + closeRegistryIfSupported(); } private void closeRegistryIfSupported() { @@ -633,16 +523,6 @@ private void closeRegistryIfSupported(BeanRegistry registry) { } } - private record YamlRuntimeState( - DefaultMainComponents mainComponents, - Configuration configuration, - boolean initialized, - RuleReinitializer reinitializer, - String yamlConfigurationLocation, - List yamlTrackedFiles - ) { - } - private static void handleOpenAPIParsingException(OpenAPIParsingException e) { System.err.printf(""" ================================================================================================ diff --git a/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java b/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java new file mode 100644 index 0000000000..acb9d068be --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java @@ -0,0 +1,25 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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 com.predic8.membrane.core.router; + +import java.nio.file.Path; +import java.util.List; + +public record YamlConfigurationSource(String location, List trackedFiles) { + + public YamlConfigurationSource { + trackedFiles = trackedFiles == null ? List.of() : List.copyOf(trackedFiles); + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java b/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java index 8f6ddd34d2..c386c44d62 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java +++ b/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java @@ -34,7 +34,7 @@ public final class YamlRouterBootstrap { private YamlRouterBootstrap() { } - public static void loadIntoRouter(DefaultRouter router, String location) throws Exception { + public static YamlConfigurationSource loadIntoRouter(DefaultRouter router, String location) throws Exception { var grammar = new GrammarAutoGenerated(); var registry = new BeanRegistryImplementation(grammar); @@ -51,12 +51,12 @@ public static void loadIntoRouter(DefaultRouter router, String location) throws beanDefinitions.ensureSingleGlobalDefinition(); beanDefinitions.getConfigDefinition().ifPresent(configBd -> router.applyConfiguration((Configuration) registry.resolve(configBd.getName()))); - router.setYamlConfiguration(location, collectTrackedFiles(location, definitions)); registry.finishStaticConfiguration(); router.getConfiguration().setBaseLocation(location); + return new YamlConfigurationSource(location, collectTrackedFiles(location, definitions)); } - private static List collectTrackedFiles(String location, List definitions) { + static List collectTrackedFiles(String location, List definitions) { LinkedHashSet trackedFiles = new LinkedHashSet<>(); Path rootSourceFile = getRootSourceFile(location); @@ -74,7 +74,7 @@ private static List collectTrackedFiles(String location, List trackedFiles(); +} diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java index 999c5f5cc6..1c334b6a2b 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java @@ -21,6 +21,8 @@ import javax.annotation.concurrent.*; +import static java.lang.Thread.currentThread; + public class DefaultHotDeployer implements HotDeployer { private static final Logger log = LoggerFactory.getLogger(DefaultHotDeployer.class.getName()); @@ -29,6 +31,7 @@ public class DefaultHotDeployer implements HotDeployer { private Thread hdt; private DefaultRouter router; + private ConfigurationReloader configurationReloader; private final Object lock = new Object(); private volatile boolean enabled = true; @@ -73,8 +76,9 @@ private void startInternal() { return; } - if (router.getYamlConfigurationLocation() != null && !router.getYamlTrackedFiles().isEmpty()) { - hdt = new YamlHotDeploymentThread(router, this); + // Start from YAML + if (configurationReloader != null && !configurationReloader.trackedFiles().isEmpty()) { + hdt = new YamlHotDeploymentThread(configurationReloader, this::isEnabled); hdt.start(); return; } @@ -85,22 +89,30 @@ private void startInternal() { @Override public void stop() { + Thread threadToStop; synchronized (lock) { if (hdt == null) return; + threadToStop = hdt; + hdt = null; + } - if (router != null && router.getReinitializer() != null) { - router.getReinitializer().stop(); - } + if (threadToStop == currentThread()) { + return; + } - if (hdt instanceof HotDeploymentThread hotDeploymentThread) { - hotDeploymentThread.stopASAP(); - } else if (hdt instanceof YamlHotDeploymentThread yamlHotDeploymentThread) { - yamlHotDeploymentThread.stopASAP(); - } else { - hdt.interrupt(); - } - hdt = null; + if (threadToStop instanceof HotDeploymentThread hotDeploymentThread) { + hotDeploymentThread.stopASAP(); + } else if (threadToStop instanceof YamlHotDeploymentThread yamlHotDeploymentThread) { + yamlHotDeploymentThread.stopASAP(); + } else { + threadToStop.interrupt(); + } + + try { + threadToStop.join(2000); + } catch (InterruptedException e) { + currentThread().interrupt(); } } @@ -108,7 +120,12 @@ public void stop() { public void setEnabled(boolean enabled) { this.enabled = enabled; - if (enabled && router != null) { + if (!enabled) { + stop(); + return; + } + + if (router != null) { startInternal(); } } @@ -117,4 +134,9 @@ public void setEnabled(boolean enabled) { public boolean isEnabled() { return enabled; } + + @Override + public void setConfigurationReloader(ConfigurationReloader configurationReloader) { + this.configurationReloader = configurationReloader; + } } diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java new file mode 100644 index 0000000000..6773a76635 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java @@ -0,0 +1,55 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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 com.predic8.membrane.core.router.hotdeploy; + +import java.nio.file.Path; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; + +final class FileWatchSnapshot { + + private final List files; + + private FileWatchSnapshot(List files) { + this.files = files; + } + + static FileWatchSnapshot capture(Collection files) { + LinkedHashSet trackedPaths = new LinkedHashSet<>(); + for (Path file : files) { + trackedPaths.add(file.toAbsolutePath().normalize()); + } + + return new FileWatchSnapshot(trackedPaths.stream() + .map(path -> new WatchedPath(path, lastModified(path))) + .toList()); + } + + boolean hasChanged() { + for (WatchedPath file : files) { + if (lastModified(file.path()) != file.lastModified()) { + return true; + } + } + return false; + } + + private static long lastModified(Path path) { + return path.toFile().lastModified(); + } + + private record WatchedPath(Path path, long lastModified) {} +} diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/HotDeployer.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/HotDeployer.java index 71592e4143..8d5fd6030a 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/HotDeployer.java +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/HotDeployer.java @@ -24,9 +24,10 @@ public interface HotDeployer { void setEnabled(boolean enabled); + default void setConfigurationReloader(ConfigurationReloader configurationReloader) {} + default boolean isEnabled() { return false; } } - diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java index 358d2b27c5..a2bef8007c 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java @@ -14,80 +14,40 @@ package com.predic8.membrane.core.router.hotdeploy; -import com.predic8.membrane.core.router.DefaultRouter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.File; -import java.util.ArrayList; -import java.util.LinkedHashSet; -import java.util.List; +import java.util.function.BooleanSupplier; + +import static com.predic8.membrane.core.router.hotdeploy.FileWatchSnapshot.capture; public class YamlHotDeploymentThread extends Thread { private static final Logger log = LoggerFactory.getLogger(YamlHotDeploymentThread.class.getName()); - private final DefaultRouter router; - private final DefaultHotDeployer hotDeployer; - private final List files = new ArrayList<>(); - - private static class FileInfo { - public String file; - public long lastModified; - } + private final ConfigurationReloader reloader; + private final BooleanSupplier enabled; + private FileWatchSnapshot snapshot; - public YamlHotDeploymentThread(DefaultRouter router, DefaultHotDeployer hotDeployer) { + public YamlHotDeploymentThread(ConfigurationReloader reloader, BooleanSupplier enabled) { super("yaml-hotdeploy"); - this.router = router; - this.hotDeployer = hotDeployer; + this.reloader = reloader; + this.enabled = enabled; refreshTrackedFiles(); } private void refreshTrackedFiles() { - files.clear(); - LinkedHashSet trackedPaths = new LinkedHashSet<>(); - for (File file : router.getYamlTrackedFiles()) { - trackedPaths.add(file.getAbsolutePath()); - File parent = file.getParentFile(); - if (parent != null) { - trackedPaths.add(parent.getAbsolutePath()); - } - } - for (String path : trackedPaths) { - FileInfo fileInfo = new FileInfo(); - fileInfo.file = path; - files.add(fileInfo); - } - updateLastModified(); - } - - private void updateLastModified() { - for (FileInfo fileInfo : files) { - fileInfo.lastModified = getLastModified(fileInfo.file); - } - } - - private static long getLastModified(String file) { - return new File(file).lastModified(); - } - - private boolean configurationChanged() { - for (FileInfo fileInfo : files) { - if (getLastModified(fileInfo.file) != fileInfo.lastModified) { - return true; - } - } - return false; + snapshot = capture(reloader.trackedFiles()); } @Override public void run() { log.debug("YAML Hot Deployment Thread started."); - while (!isInterrupted() && hotDeployer.isEnabled()) { + while (!isInterrupted() && enabled.getAsBoolean()) { try { - while (!configurationChanged()) { - if (isInterrupted() || !hotDeployer.isEnabled()) { + while (!snapshot.hasChanged()) { + if (isInterrupted() || !enabled.getAsBoolean()) { log.debug("YAML Hot Deployment Thread interrupted."); return; } @@ -96,13 +56,13 @@ public void run() { sleep(1000); } - log.debug("yaml configuration changed."); + log.info("yaml configuration changed."); - if (!router.reloadYamlConfiguration()) { + if (!reloader.reload()) { break; } - if (!hotDeployer.isEnabled()) { + if (!enabled.getAsBoolean()) { break; } diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java new file mode 100644 index 0000000000..e00dd10e8c --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java @@ -0,0 +1,108 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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 com.predic8.membrane.core.router.hotdeploy; + +import com.predic8.membrane.core.router.DefaultRouter; +import com.predic8.membrane.core.router.YamlConfigurationSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.concurrent.GuardedBy; +import java.nio.file.Path; +import java.util.List; + +import static com.predic8.membrane.core.router.YamlRouterBootstrap.loadIntoRouter; + +public final class YamlRouterReloader implements ConfigurationReloader { + + private static final Logger log = LoggerFactory.getLogger(YamlRouterReloader.class); + + private final DefaultRouter router; + + @GuardedBy("this") + private YamlConfigurationSource source; + + public YamlRouterReloader(DefaultRouter router, YamlConfigurationSource source) { + this.router = router; + this.source = source; + } + + @Override + public synchronized List trackedFiles() { + return source == null ? List.of() : source.trackedFiles(); + } + + @Override + public boolean reload() { + YamlConfigurationSource currentSource = getSource(); + boolean runtimeStopped = false; + + if (!router.markReloading()) { + return false; + } + + try { + if (currentSource == null || currentSource.location() == null || currentSource.location().isBlank()) { + throw new IllegalStateException("No YAML configuration location is known."); + } + + log.info("Reloading YAML configuration from {}.", currentSource.location()); + validate(currentSource.location()); + + router.stopRuntimeForReload(); + runtimeStopped = true; + router.resetRuntime(); + + setSource(loadIntoRouter(router, currentSource.location())); + router.start(); + log.info("YAML configuration reloaded successfully."); + return true; + } catch (Exception e) { + log.error("Could not reload YAML configuration.", e); + if (!runtimeStopped) { + log.info("Keeping the previous YAML runtime because reload validation failed before shutdown."); + return true; + } + try { + router.stopRuntimeForReload(); + router.disposeRuntime(); + } catch (Exception cleanupError) { + cleanupError.addSuppressed(e); + log.error("Could not clean up the failed YAML runtime after reload failure.", cleanupError); + } + log.info("YAML runtime remains stopped after reload failure."); + return false; + } finally { + router.clearReloading(); + } + } + + private void validate(String location) throws Exception { + DefaultRouter candidate = new DefaultRouter(); + try { + loadIntoRouter(candidate, location); + } finally { + candidate.disposeRuntime(); + } + } + + private synchronized YamlConfigurationSource getSource() { + return source; + } + + private synchronized void setSource(YamlConfigurationSource source) { + this.source = source; + } +} From c61ec0a77bb745747087864a49398b001999824b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Thu, 28 May 2026 10:23:47 +0200 Subject: [PATCH 08/12] Add tests for YAML hot deployment with error handling and recovery --- .../core/router/YamlHotDeploymentTest.java | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 core/src/test/java/com/predic8/membrane/core/router/YamlHotDeploymentTest.java diff --git a/core/src/test/java/com/predic8/membrane/core/router/YamlHotDeploymentTest.java b/core/src/test/java/com/predic8/membrane/core/router/YamlHotDeploymentTest.java new file mode 100644 index 0000000000..6429c8f4a7 --- /dev/null +++ b/core/src/test/java/com/predic8/membrane/core/router/YamlHotDeploymentTest.java @@ -0,0 +1,120 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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 com.predic8.membrane.core.router; + +import com.predic8.membrane.core.proxies.Proxy; +import com.predic8.membrane.core.router.hotdeploy.YamlRouterReloader; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static com.predic8.membrane.core.router.YamlRouterBootstrap.loadIntoRouter; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class YamlHotDeploymentTest { + + @TempDir + Path tempDir; + + @Test + void shouldKeepPreviousRuntimeWhenValidationFails() throws Exception { + Path config = tempDir.resolve("apis.yaml"); + Files.writeString(config, configWithInternal("first", "https://example.com")); + + DefaultRouter router = new DefaultRouter(); + try { + YamlRouterReloader reloader = new YamlRouterReloader(router, loadIntoRouter(router, config.toString())); + router.setConfigurationReloader(reloader); + router.start(); + + Files.writeString(config, """ + configuration: + hotDeploy: false + --- + internal: + name: broken + target: + url: "https://example.org + """); + + assertTrue(reloader.reload()); + assertTrue(router.isRunning()); + assertEquals(List.of("first"), getRuleNames(router)); + } finally { + router.stop(); + } + } + + @Test + void shouldLeaveRouterStoppedWhenReloadFailsAfterShutdown() throws Exception { + Path config = tempDir.resolve("apis.yaml"); + Files.writeString(config, configWithInternal("first", "https://example.com")); + + FailingReloadStartRouter router = new FailingReloadStartRouter(); + try { + YamlRouterReloader reloader = new YamlRouterReloader(router, loadIntoRouter(router, config.toString())); + router.setConfigurationReloader(reloader); + router.start(); + + Files.writeString(config, configWithInternal("second", "https://example.org")); + router.failNextStart(); + + assertFalse(reloader.reload()); + assertFalse(router.isRunning()); + } finally { + router.stop(); + } + } + + private static List getRuleNames(DefaultRouter router) { + return router.getRuleManager().getRules().stream() + .map(Proxy::getName) + .toList(); + } + + private static String configWithInternal(String name, String url) { + return """ + configuration: + hotDeploy: false + --- + internal: + name: %s + target: + url: %s + """.formatted(name, url); + } + + private static class FailingReloadStartRouter extends DefaultRouter { + private boolean failNextStart; + + void failNextStart() { + failNextStart = true; + } + + @Override + public void start() { + if (failNextStart) { + failNextStart = false; + throw new RuntimeException("simulated reload start failure"); + } + super.start(); + } + } +} From 0ab75db9e5b758ca7a1a205cf496051a76892cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Thu, 28 May 2026 11:11:02 +0200 Subject: [PATCH 09/12] Simplify logging messages for YAML configuration reload and change detection --- .../core/router/hotdeploy/YamlHotDeploymentThread.java | 2 +- .../membrane/core/router/hotdeploy/YamlRouterReloader.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java index a2bef8007c..fa8be9033b 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java @@ -56,7 +56,7 @@ public void run() { sleep(1000); } - log.info("yaml configuration changed."); + log.info("Configuration Changed."); if (!reloader.reload()) { break; diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java index e00dd10e8c..3b761dcd2f 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java @@ -58,7 +58,7 @@ public boolean reload() { throw new IllegalStateException("No YAML configuration location is known."); } - log.info("Reloading YAML configuration from {}.", currentSource.location()); + log.debug("Reloading YAML configuration from {}.", currentSource.location()); validate(currentSource.location()); router.stopRuntimeForReload(); @@ -67,7 +67,7 @@ public boolean reload() { setSource(loadIntoRouter(router, currentSource.location())); router.start(); - log.info("YAML configuration reloaded successfully."); + log.info("Configuration Reloaded."); return true; } catch (Exception e) { log.error("Could not reload YAML configuration.", e); From e46a45a91ef7100f06a4a607122e7195334e932e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Thu, 28 May 2026 11:18:18 +0200 Subject: [PATCH 10/12] Refactor error logging for YAML configuration reload failures --- .../core/router/hotdeploy/YamlRouterReloader.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java index 3b761dcd2f..d397bfcc5a 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java @@ -14,6 +14,7 @@ package com.predic8.membrane.core.router.hotdeploy; +import com.predic8.membrane.annot.yaml.ConfigurationParsingException; import com.predic8.membrane.core.router.DefaultRouter; import com.predic8.membrane.core.router.YamlConfigurationSource; import org.slf4j.Logger; @@ -70,7 +71,7 @@ public boolean reload() { log.info("Configuration Reloaded."); return true; } catch (Exception e) { - log.error("Could not reload YAML configuration.", e); + logReloadFailure(e); if (!runtimeStopped) { log.info("Keeping the previous YAML runtime because reload validation failed before shutdown."); return true; @@ -89,6 +90,14 @@ public boolean reload() { } } + private void logReloadFailure(Exception e) { + if (e instanceof ConfigurationParsingException) { + log.error("Could not reload YAML configuration: {}", e.getMessage()); + return; + } + log.error("Could not reload YAML configuration.", e); + } + private void validate(String location) throws Exception { DefaultRouter candidate = new DefaultRouter(); try { From dca061e908e4728b3679f41c66cd6c3e9e1c0d09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Thu, 28 May 2026 11:31:46 +0200 Subject: [PATCH 11/12] Add documentation comments for YAML hot deployment and reloading components --- .../com/predic8/membrane/core/router/YamlRouterBootstrap.java | 3 +++ .../membrane/core/router/hotdeploy/DefaultHotDeployer.java | 1 + .../membrane/core/router/hotdeploy/FileWatchSnapshot.java | 3 +++ .../core/router/hotdeploy/YamlHotDeploymentThread.java | 3 +++ .../membrane/core/router/hotdeploy/YamlRouterReloader.java | 4 ++++ 5 files changed, 14 insertions(+) diff --git a/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java b/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java index c386c44d62..201f35d12a 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java +++ b/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java @@ -29,6 +29,9 @@ import static com.predic8.membrane.core.util.OSUtil.isWindowsAbsolutePath; import static com.predic8.membrane.core.util.URIUtil.pathFromFileURI; +/** + * Parses YAML into a router instance and returns the local files that can later be watched for hot reloads. + */ public final class YamlRouterBootstrap { private YamlRouterBootstrap() { diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java index 1c334b6a2b..aaf2710fae 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java @@ -97,6 +97,7 @@ public void stop() { hdt = null; } + // A watcher can stop itself after a failed reload; joining the current thread would deadlock here. if (threadToStop == currentThread()) { return; } diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java index 6773a76635..3141aa4a46 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java @@ -19,6 +19,9 @@ import java.util.LinkedHashSet; import java.util.List; +/** + * Snapshot of the currently watched YAML files and their last-modified timestamps. + */ final class FileWatchSnapshot { private final List files; diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java index fa8be9033b..702f79e4f9 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java @@ -21,6 +21,9 @@ import static com.predic8.membrane.core.router.hotdeploy.FileWatchSnapshot.capture; +/** + * Polling watcher for the tracked YAML files; it only detects changes and delegates reload decisions. + */ public class YamlHotDeploymentThread extends Thread { private static final Logger log = LoggerFactory.getLogger(YamlHotDeploymentThread.class.getName()); diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java index d397bfcc5a..c5e45f6f2b 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java @@ -26,6 +26,9 @@ import static com.predic8.membrane.core.router.YamlRouterBootstrap.loadIntoRouter; +/** + * Performs YAML validate-and-reload orchestration around the router lifecycle. + */ public final class YamlRouterReloader implements ConfigurationReloader { private static final Logger log = LoggerFactory.getLogger(YamlRouterReloader.class); @@ -60,6 +63,7 @@ public boolean reload() { } log.debug("Reloading YAML configuration from {}.", currentSource.location()); + // Validation only bootstraps a fresh router; it does not call start() or bind ports. validate(currentSource.location()); router.stopRuntimeForReload(); From 31cfdadd771ed5bbfd59fcb97691c05ea629bf26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Thu, 28 May 2026 11:36:55 +0200 Subject: [PATCH 12/12] Add Javadoc comment for YamlConfigurationSource --- .../predic8/membrane/core/router/YamlConfigurationSource.java | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java b/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java index acb9d068be..a650652483 100644 --- a/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java +++ b/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java @@ -17,6 +17,7 @@ import java.nio.file.Path; import java.util.List; +/** Root YAML location and included local YAML files. */ public record YamlConfigurationSource(String location, List trackedFiles) { public YamlConfigurationSource {