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 5a9bf86d52..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 @@ -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.hotdeploy.YamlRouterReloader; 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; @@ -58,9 +51,9 @@ 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.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; @@ -236,46 +229,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); - + router.setConfigurationReloader(new YamlRouterReloader(router, 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..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,9 +36,9 @@ 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.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; @@ -111,6 +111,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 @@ -299,12 +302,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 +354,7 @@ public String getId() { */ public void waitFor() { synchronized (lock) { - while (running) { + while (running || reloading) { try { lock.wait(); } catch (InterruptedException ignored) { @@ -430,7 +437,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 +461,68 @@ public RuleReinitializer getReinitializer() { return reinitializer; } + public void setConfigurationReloader(ConfigurationReloader configurationReloader) { + hotDeployer.setConfigurationReloader(configurationReloader); + } + + public boolean markReloading() { + synchronized (lock) { + if (reloading) { + return false; + } + reloading = true; + lock.notifyAll(); + return true; + } + } + + public void clearReloading() { + synchronized (lock) { + reloading = false; + lock.notifyAll(); + } + } + + public void stopRuntimeForReload() { + getRegistry().getBean(KubernetesWatcher.class).ifPresent(KubernetesWatcher::stop); + + if (reinitializer != null) + reinitializer.stop(); + if (mainComponents.getTransport() != null) + mainComponents.getTransport().closeAll(false); + + synchronized (lock) { + running = false; + lock.notifyAll(); + } + } + + public void resetRuntime() { + synchronized (lock) { + mainComponents = new DefaultMainComponents(this); + configuration = new Configuration(); + initialized = false; + reinitializer = null; + } + } + + public void disposeRuntime() { + if (reinitializer != null) + reinitializer.stop(); + mainComponents.getTimerManager().shutdown(); + closeRegistryIfSupported(); + } + + private void closeRegistryIfSupported() { + closeRegistryIfSupported(getRegistry()); + } + + private void closeRegistryIfSupported(BeanRegistry registry) { + if (registry instanceof BeanRegistryImplementation beanRegistry) { + beanRegistry.close(); + } + } + private static void handleOpenAPIParsingException(OpenAPIParsingException e) { System.err.printf(""" ================================================================================================ @@ -483,4 +552,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/YamlConfigurationSource.java b/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java new file mode 100644 index 0000000000..a650652483 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java @@ -0,0 +1,26 @@ +/* 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; + +/** Root YAML location and included local YAML files. */ +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 new file mode 100644 index 0000000000..201f35d12a --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java @@ -0,0 +1,97 @@ +/* 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; + +/** + * 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() { + } + + public static YamlConfigurationSource 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()))); + + registry.finishStaticConfiguration(); + router.getConfiguration().setBaseLocation(location); + return new YamlConfigurationSource(location, collectTrackedFiles(location, definitions)); + } + + 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); + } + + 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/ConfigurationReloader.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/ConfigurationReloader.java new file mode 100644 index 0000000000..daa7f541a2 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/ConfigurationReloader.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.hotdeploy; + +import java.nio.file.Path; +import java.util.List; + +public interface ConfigurationReloader { + + boolean reload(); + + 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 591214c5ee..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 @@ -21,16 +21,20 @@ 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()); @GuardedBy("lock") - private HotDeploymentThread hdt; + private Thread hdt; private DefaultRouter router; + private ConfigurationReloader configurationReloader; private final Object lock = new Object(); + private volatile boolean enabled = true; @Override public void start(@NotNull DefaultRouter defaultRouter) { @@ -41,8 +45,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,38 +69,75 @@ 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; + } + + // Start from YAML + if (configurationReloader != null && !configurationReloader.trackedFiles().isEmpty()) { + hdt = new YamlHotDeploymentThread(configurationReloader, this::isEnabled); 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."); } } @Override public void stop() { + Thread threadToStop; synchronized (lock) { if (hdt == null) return; - - router.getReinitializer().stop(); - hdt.stopASAP(); + threadToStop = hdt; hdt = null; } + + // A watcher can stop itself after a failed reload; joining the current thread would deadlock here. + if (threadToStop == currentThread()) { + return; + } + + 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(); + } } @Override public void setEnabled(boolean enabled) { - if (enabled && router != null) - startInternal(); - else + this.enabled = enabled; + + if (!enabled) { stop(); + return; + } + + if (router != null) { + startInternal(); + } } @Override public boolean isEnabled() { - return true; + 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..3141aa4a46 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java @@ -0,0 +1,58 @@ +/* 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; + +/** + * Snapshot of the currently watched YAML files and their last-modified timestamps. + */ +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 new file mode 100644 index 0000000000..702f79e4f9 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java @@ -0,0 +1,87 @@ +/* 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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.function.BooleanSupplier; + +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()); + + private final ConfigurationReloader reloader; + private final BooleanSupplier enabled; + private FileWatchSnapshot snapshot; + + public YamlHotDeploymentThread(ConfigurationReloader reloader, BooleanSupplier enabled) { + super("yaml-hotdeploy"); + this.reloader = reloader; + this.enabled = enabled; + refreshTrackedFiles(); + } + + private void refreshTrackedFiles() { + snapshot = capture(reloader.trackedFiles()); + } + + @Override + public void run() { + log.debug("YAML Hot Deployment Thread started."); + + while (!isInterrupted() && enabled.getAsBoolean()) { + try { + while (!snapshot.hasChanged()) { + if (isInterrupted() || !enabled.getAsBoolean()) { + log.debug("YAML Hot Deployment Thread interrupted."); + return; + } + + //noinspection BusyWait + sleep(1000); + } + + log.info("Configuration Changed."); + + if (!reloader.reload()) { + break; + } + + if (!enabled.getAsBoolean()) { + 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(); + } +} 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..c5e45f6f2b --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java @@ -0,0 +1,121 @@ +/* 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.annot.yaml.ConfigurationParsingException; +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; + +/** + * 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); + + 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.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(); + runtimeStopped = true; + router.resetRuntime(); + + setSource(loadIntoRouter(router, currentSource.location())); + router.start(); + log.info("Configuration Reloaded."); + return true; + } catch (Exception e) { + logReloadFailure(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 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 { + loadIntoRouter(candidate, location); + } finally { + candidate.disposeRuntime(); + } + } + + private synchronized YamlConfigurationSource getSource() { + return source; + } + + private synchronized void setSource(YamlConfigurationSource source) { + this.source = source; + } +} 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(); + } + } +}