From f5022fd05cef1ff23f3159023903be96bd4deaf8 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Mon, 11 May 2026 22:48:54 -0400 Subject: [PATCH] CAMEL-23335: camel-jbang - Lazy plugin discovery and resolved-classpath cache Two related changes to remove per-invocation plugin overhead from `camel`: * `CamelJBangMain.execute` now consults a new `PluginHelper.shouldDiscoverPlugins` gate before calling `addPlugins`. Built-in commands that do not consume plugins (e.g. `version`, `get`, `ps`, `stop`) short-circuit the plugin JSON read and FACTORY_FINDER classpath scan entirely. Plugin-consuming built-ins (`run`, `export`, `cmd`, `shell`), unknown subcommands (likely plugin-provided), and no-args/help still discover so plugin commands remain visible. * `PluginHelper.resolvePlugin` now reads a `resolved` block from the per-plugin entry in `~/.camel-jbang-plugins.json`. When present and valid (Camel version, gav, repos match; cached jars and the plugin POM unchanged by size+mtime), the plugin is loaded directly from a URLClassLoader over the cached jars, skipping FACTORY_FINDER and the Maven downloader. The resolved block is populated on the first successful Maven resolution; SNAPSHOT plugins rebuilt locally are picked up automatically via the mtime check. Tests cover the gate's classification, cache hit, mtime-based invalidation, the write path, and a paired before/after demonstration of the cache fast path (no resolved block -> resolver invoked and quits; resolved block present -> plugin loaded from the cached jar without invoking the resolver). The existing `testCacheInvalidatedOnMtimeChange` is cleaned up to use `assertThrows` instead of a try/empty-catch block. Upgrade guide updated. Co-Authored-By: Claude Opus 4.7 rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED --- .../pages/camel-4x-upgrade-guide-4_21.adoc | 17 ++ .../jbang/core/commands/CamelJBangMain.java | 2 +- .../dsl/jbang/core/common/PluginHelper.java | 281 +++++++++++++++++- .../jbang/core/common/CachedFakePlugin.java | 32 ++ .../dsl/jbang/core/common/FakePluginJar.java | 59 ++++ .../jbang/core/common/PluginHelperTest.java | 190 ++++++++++++ 6 files changed, 568 insertions(+), 13 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/CachedFakePlugin.java create mode 100644 dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/FakePluginJar.java diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc index 02af0fb2e1fc8..968497bc7191a 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc @@ -46,6 +46,23 @@ and dev consoles for nodes inside Choice EIP branches. The `camel wrapper` command now installs the scripts as `camel` instead of `camelw`. You can use the `--command-name=camelw` to use the old name. +Plugins are now loaded lazily. Built-in commands that do not consume plugins +(for example `camel get`, `camel version`, `camel ps`, `camel stop`) skip plugin +discovery entirely, avoiding classpath scans and Maven resolution on every +invocation. Plugin-consuming commands (`run`, `export`, `cmd`, `shell`) and +plugin-provided commands (such as `kubernetes`, `generate`, `test`) continue +to work unchanged. + +When an external plugin is resolved through Maven, its resolved classpath is +cached in `~/.camel-jbang-plugins.json` under a new `resolved` block. Subsequent +invocations load the plugin directly from the cached jars without going through +the Maven downloader. The cache is validated by file size and modification time +on both the cached jars and the plugin's POM, so SNAPSHOT plugins rebuilt +locally are picked up automatically. The cache is also invalidated when the +Camel version, the plugin GAV, or the effective `--repos`/`--repo` value +changes. No user action is required; existing plugin entries are populated on +first use after upgrade. + === camel-yaml-dsl A new canonical JSON Schema variant (`camelYamlDsl-canonical.json`) has been added alongside the existing classic diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java index 477769c861254..b1cda9361fc98 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java @@ -213,7 +213,7 @@ public void execute(String... args) { postAddCommands(commandLine, args); - if (discoverPlugins) { + if (discoverPlugins && PluginHelper.shouldDiscoverPlugins(commandLine, args)) { PluginHelper.addPlugins(commandLine, this, args); } diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java index 0ce0100bb7e0b..36b01f5e3edab 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java @@ -19,14 +19,20 @@ import java.io.IOException; import java.io.InputStream; import java.net.URL; +import java.net.URLClassLoader; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collection; import java.util.Enumeration; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Properties; +import java.util.Set; import java.util.function.Supplier; import java.util.jar.JarEntry; import java.util.jar.JarFile; @@ -57,6 +63,13 @@ public final class PluginHelper { public static final String PLUGIN_CONFIG = ".camel-jbang-plugins.json"; public static final String PLUGIN_SERVICE_DIR = "META-INF/services/org/apache/camel/camel-jbang-plugin/"; + /** + * Built-in top-level commands that consume plugins — either by accepting plugin-contributed sub-options (run, + * export) or by dispatching to plugin-provided commands (shell, cmd). Plugin discovery must still run for these + * even if the target name is registered as a built-in subcommand. + */ + private static final Set PLUGIN_CONSUMING_BUILTINS = Set.of("shell", "run", "export", "cmd"); + private static final FactoryFinder FACTORY_FINDER = new DefaultFactoryFinder(new DefaultClassResolver(), FactoryFinder.DEFAULT_PATH + "camel-jbang-plugin/"); @@ -64,6 +77,35 @@ private PluginHelper() { // prevent instantiation of utility class } + /** + * Decides whether plugin discovery (classpath scan + JSON config + Maven resolution) is needed for the current + * invocation. Returns false when the target command is a built-in that does not consume plugins, skipping all + * plugin-related IO. Returns true for plugin-consuming built-ins (run/export/cmd/shell), for unknown commands + * (likely plugin-provided), and when no target is given (e.g. --help listing). + * + * @param commandLine the command line with all built-in subcommands already registered + * @param args the raw CLI args; only args[0] is inspected + * @return true if plugin discovery should run, false to short-circuit + */ + public static boolean shouldDiscoverPlugins(CommandLine commandLine, String... args) { + if (args == null || args.length == 0) { + return true; + } + // Only args[0] is inspected. If the user puts global options before the subcommand + // (e.g. `camel --verbose version`), we conservatively load plugins. Picocli option grammar + // is non-trivial enough that a heuristic skip would risk false negatives; the missed + // optimization is acceptable since this prefix-options pattern is uncommon. + String target = args[0]; + if (target == null || target.isBlank() || target.startsWith("-")) { + return true; + } + if (PLUGIN_CONSUMING_BUILTINS.contains(target)) { + return true; + } + // target is a built-in (and not a plugin-consuming one) → no plugin needed + return !commandLine.getSubcommands().containsKey(target); + } + /** * Loads the plugin Json configuration from the user home and goes through all configured plugins adding the plugin * commands to the current command line. Tries to resolve each plugin from the classpath with the factory finder @@ -161,6 +203,7 @@ public static Map getActivePlugins(CamelJBangMain main, String r String version = catalog.getCatalogVersion(); JsonObject plugins = config.getMap("plugins"); + boolean configDirty = false; for (String pluginKey : plugins.keySet()) { JsonObject properties = plugins.getMap(pluginKey); @@ -179,30 +222,243 @@ public static Map getActivePlugins(CamelJBangMain main, String r versionCheck(main, version, firstVersion, command); } - Optional plugin = getPlugin(command, version, gav, repos, main.getOut()); - if (plugin.isPresent()) { - activePlugins.put(command, plugin.get()); + ResolveResult res = resolvePlugin(properties, command, version, gav, repos, main.getOut()); + if (res.plugin().isPresent()) { + activePlugins.put(command, res.plugin().get()); + if (res.cacheWritten()) { + configDirty = true; + } } else { main.getOut().println("camel-jbang-plugin-" + command + " not found. Exit"); main.quit(1); } } + if (configDirty) { + savePluginConfig(config); + } } return activePlugins; } public static Optional getPlugin(String name, String defaultVersion, String gav, String repos, Printer printer) { + return resolvePlugin(null, name, defaultVersion, gav, repos, printer).plugin(); + } + + /** + * Resolves a plugin by trying, in order: the cached metadata in the plugin entry (fast path with no IO beyond + * size+mtime checks), the factory finder (embedded plugin on the JVM classpath), and finally the Maven downloader. + * When the downloader runs, the resolved classpath is captured into the plugin entry's {@code resolved} block so + * subsequent invocations take the fast path. + */ + private static ResolveResult resolvePlugin( + JsonObject entry, String name, String defaultVersion, String gav, String repos, Printer printer) { + Optional cached = loadFromCache(entry, defaultVersion, gav, repos); + if (cached.isPresent()) { + return new ResolveResult(cached, false); + } + Optional plugin = FACTORY_FINDER.newInstance("camel-jbang-plugin-" + name, Plugin.class); - if (plugin.isEmpty()) { - final MavenGav mavenGav = dependencyAsMavenGav(gav); - final String group = extractGroup(mavenGav, "org.apache.camel"); - final String depVersion = extractVersion(mavenGav, defaultVersion); + if (plugin.isPresent()) { + return new ResolveResult(plugin, false); + } + + final MavenGav mavenGav = dependencyAsMavenGav(gav); + final String group = extractGroup(mavenGav, "org.apache.camel"); + final String depVersion = extractVersion(mavenGav, defaultVersion); + + DownloadResult dr = downloadPlugin(name, defaultVersion, depVersion, group, repos, printer); + boolean cacheWritten = false; + if (dr.plugin().isPresent() && entry != null && dr.classLoader() != null && dr.className() != null) { + cacheWritten = writeCache(entry, defaultVersion, gav, repos, dr.className(), dr.classLoader(), name, depVersion); + } + return new ResolveResult(dr.plugin(), cacheWritten); + } + + private static Optional loadFromCache(JsonObject entry, String camelVersion, String gav, String repos) { + if (entry == null) { + return Optional.empty(); + } + JsonObject resolved = entry.getMap("resolved"); + if (resolved == null) { + return Optional.empty(); + } + if (!sameCamelVersion(asString(resolved.get("camelVersion")), camelVersion)) { + return Optional.empty(); + } + if (!Objects.equals(normalize(asString(resolved.get("gav"))), normalize(gav))) { + return Optional.empty(); + } + if (!Objects.equals(normalize(asString(resolved.get("repos"))), normalize(repos))) { + return Optional.empty(); + } + String className = asString(resolved.get("className")); + if (className == null || className.isBlank()) { + return Optional.empty(); + } + Object cpObj = resolved.get("classpath"); + if (!(cpObj instanceof Collection)) { + return Optional.empty(); + } + Collection classpath = (Collection) cpObj; + if (classpath.isEmpty()) { + return Optional.empty(); + } + + List urls = new ArrayList<>(classpath.size()); + for (Object o : classpath) { + if (!(o instanceof Map)) { + return Optional.empty(); + } + Map jar = (Map) o; + Path p = validateFileEntry(jar); + if (p == null) { + return Optional.empty(); + } + try { + urls.add(p.toUri().toURL()); + } catch (IOException e) { + return Optional.empty(); + } + } + + // If the cache tracks the plugin POM, validate it too. Detects POM-only changes (e.g. a SNAPSHOT + // plugin's transitive deps changed without a jar rebuild). + Object pomObj = resolved.get("pom"); + if (pomObj instanceof Map pom) { + if (validateFileEntry(pom) == null) { + return Optional.empty(); + } + } + + try { + URLClassLoader cl = new URLClassLoader(urls.toArray(new URL[0]), PluginHelper.class.getClassLoader()); + Class pluginClass = cl.loadClass(className); + Plugin instance = (Plugin) ObjectHelper.newInstance(pluginClass); + instance.setClassLoader(cl); + return Optional.of(instance); + } catch (Exception e) { + return Optional.empty(); + } + } - plugin = downloadPlugin(name, defaultVersion, depVersion, group, repos, printer); + /** + * Persists the resolved plugin classpath into the entry's {@code resolved} block. Package-private so unit tests can + * drive the happy path without invoking the Maven downloader. Also tracks the plugin's own POM file (size+mtime) so + * a POM-only change (e.g. a SNAPSHOT plugin gaining a new transitive dependency without a jar rebuild) invalidates + * the cache on the next invocation. + */ + static boolean writeCache( + JsonObject entry, String camelVersion, String gav, String repos, String className, ClassLoader cl, + String pluginCommand, String pluginVersion) { + URL[] urls; + if (cl instanceof URLClassLoader ucl) { + urls = ucl.getURLs(); + } else { + return false; + } + if (urls == null || urls.length == 0) { + return false; + } + Collection classpath = new ArrayList<>(urls.length); + JsonObject pomEntry = null; + String pluginJarName = "camel-jbang-plugin-" + pluginCommand + "-" + pluginVersion + ".jar"; + for (URL u : urls) { + try { + Path p = Path.of(u.toURI()); + if (!Files.exists(p)) { + return false; + } + JsonObject jar = new JsonObject(); + jar.put("path", p.toAbsolutePath().toString()); + jar.put("size", Files.size(p)); + jar.put("mtime", Files.getLastModifiedTime(p).toMillis()); + classpath.add(jar); + + // Identify the plugin's own jar by filename and track the sibling POM, so a Maven re-install + // of the plugin (which always rewrites the POM) is detected even when the jar bytes happen + // to be unchanged. + if (pomEntry == null && pluginJarName.equals(p.getFileName().toString())) { + Path pom = p.resolveSibling("camel-jbang-plugin-" + pluginCommand + "-" + pluginVersion + ".pom"); + if (Files.exists(pom)) { + pomEntry = new JsonObject(); + pomEntry.put("path", pom.toAbsolutePath().toString()); + pomEntry.put("size", Files.size(pom)); + pomEntry.put("mtime", Files.getLastModifiedTime(pom).toMillis()); + } + } + } catch (Exception e) { + return false; + } + } + JsonObject resolved = new JsonObject(); + resolved.put("camelVersion", camelVersion); + if (normalize(gav) != null) { + resolved.put("gav", normalize(gav)); + } + if (normalize(repos) != null) { + resolved.put("repos", normalize(repos)); } + resolved.put("className", className); + resolved.put("cachedAt", System.currentTimeMillis()); + resolved.put("classpath", classpath); + if (pomEntry != null) { + resolved.put("pom", pomEntry); + } + entry.put("resolved", resolved); + return true; + } + + /** + * Validates a {path, size, mtime} entry from the cache against the actual file on disk. Returns the resolved Path + * on match, or null if the file is missing, was modified, or the entry is malformed. + */ + private static Path validateFileEntry(Map entry) { + String path = asString(entry.get("path")); + Object sizeObj = entry.get("size"); + Object mtimeObj = entry.get("mtime"); + if (path == null || !(sizeObj instanceof Number) || !(mtimeObj instanceof Number)) { + return null; + } + long size = ((Number) sizeObj).longValue(); + long mtime = ((Number) mtimeObj).longValue(); + Path p = Path.of(path); + try { + if (!Files.exists(p) || Files.size(p) != size || Files.getLastModifiedTime(p).toMillis() != mtime) { + return null; + } + return p; + } catch (IOException e) { + return null; + } + } + + private static boolean sameCamelVersion(String a, String b) { + return stripSnapshot(a).equals(stripSnapshot(b)); + } + + private static String stripSnapshot(String v) { + if (v == null) { + return ""; + } + return v.endsWith("-SNAPSHOT") ? v.substring(0, v.length() - "-SNAPSHOT".length()) : v; + } + + private static String normalize(String s) { + if (s == null || s.isBlank()) { + return null; + } + return s.trim(); + } + + private static String asString(Object o) { + return o == null ? null : o.toString(); + } + + private record ResolveResult(Optional plugin, boolean cacheWritten) { + } - return plugin; + private record DownloadResult(Optional plugin, ClassLoader classLoader, String className) { } private static MavenGav dependencyAsMavenGav(String gav) { @@ -227,7 +483,7 @@ static void versionCheck(CamelJBangMain main, String version, String firstVersio } } - private static Optional downloadPlugin( + private static DownloadResult downloadPlugin( String command, String camelVersion, String version, String group, String repos, Printer printer) { DependencyDownloader downloader = new MavenDependencyDownloader(); DependencyDownloaderClassLoader ddlcl = new DependencyDownloaderClassLoader(PluginHelper.class.getClassLoader()); @@ -242,6 +498,7 @@ private static Optional downloadPlugin( downloader.downloadDependencyWithParent("org.apache.camel:camel-jbang-parent:pom:" + camelVersion, group, "camel-jbang-plugin-" + command, version); Optional instance = Optional.empty(); + String pluginClassName = null; InputStream in = null; String path = FactoryFinder.DEFAULT_PATH + "camel-jbang-plugin/camel-jbang-plugin-" + command; try { @@ -250,7 +507,7 @@ private static Optional downloadPlugin( if (in != null) { Properties prop = new Properties(); prop.load(in); - String pluginClassName = prop.getProperty("class"); + pluginClassName = prop.getProperty("class"); DefaultClassResolver resolver = new DefaultClassResolver(); Class pluginClass = resolver.resolveClass(pluginClassName, ddlcl); instance = Optional.of(Plugin.class.cast(ObjectHelper.newInstance(pluginClass))); @@ -270,7 +527,7 @@ private static Optional downloadPlugin( } IOHelper.close(in); } - return instance; + return new DownloadResult(instance, ddlcl, pluginClassName); } public static JsonObject getOrCreatePluginConfig() { diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/CachedFakePlugin.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/CachedFakePlugin.java new file mode 100644 index 0000000000000..46dd6491ff099 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/CachedFakePlugin.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.camel.dsl.jbang.core.common; + +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import picocli.CommandLine; + +/** + * Minimal Plugin implementation packaged into a fake jar by {@link FakePluginJar} so that the cache fast path can be + * exercised end-to-end without invoking the Maven downloader. + */ +public class CachedFakePlugin implements Plugin { + + @Override + public void customize(CommandLine commandLine, CamelJBangMain main) { + // no-op + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/FakePluginJar.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/FakePluginJar.java new file mode 100644 index 0000000000000..08e85baeb288a --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/FakePluginJar.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.camel.dsl.jbang.core.common; + +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +/** + * Builds a tiny jar containing the precompiled CachedFakePlugin class so cache-hit tests can verify the fast path loads + * a class from a URLClassLoader without going through the Maven downloader. + */ +final class FakePluginJar { + + static final String PLUGIN_CLASS = CachedFakePlugin.class.getName(); + + private FakePluginJar() { + } + + static void write(Path target, String pluginName) throws Exception { + // Copy the existing .class for CachedFakePlugin into a jar; that class implements Plugin already. + String classResource = PLUGIN_CLASS.replace('.', '/') + ".class"; + byte[] classBytes; + try (var in = FakePluginJar.class.getClassLoader().getResourceAsStream(classResource)) { + if (in == null) { + throw new IllegalStateException("Missing test class resource: " + classResource); + } + classBytes = in.readAllBytes(); + } + try (OutputStream fos = Files.newOutputStream(target); + JarOutputStream jos = new JarOutputStream(fos)) { + JarEntry classEntry = new JarEntry(classResource); + jos.putNextEntry(classEntry); + jos.write(classBytes); + jos.closeEntry(); + + JarEntry svc = new JarEntry(PluginHelper.PLUGIN_SERVICE_DIR + "camel-jbang-plugin-" + pluginName); + jos.putNextEntry(svc); + jos.write(("class=" + PLUGIN_CLASS + "\n").getBytes()); + jos.closeEntry(); + } + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginHelperTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginHelperTest.java index 0a1b9db38ff94..c9a0255953d2d 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginHelperTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginHelperTest.java @@ -26,10 +26,13 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; public class PluginHelperTest { @@ -131,4 +134,191 @@ public void testGetActivePluginsNoFilterLoadsAll() throws Exception { JsonObject pluginsConfig = config.getMap("plugins"); assertEquals(1, pluginsConfig.size()); } + + @Test + public void testShouldDiscoverPlugins() { + CamelJBangMain main = new CamelJBangMain(); + CommandLine cl = new CommandLine(main); + cl.addSubcommand("version", new CommandLine(new NoOpCommand())); + cl.addSubcommand("get", new CommandLine(new NoOpCommand())); + cl.addSubcommand("run", new CommandLine(new NoOpCommand())); + cl.addSubcommand("export", new CommandLine(new NoOpCommand())); + cl.addSubcommand("shell", new CommandLine(new NoOpCommand())); + cl.addSubcommand("cmd", new CommandLine(new NoOpCommand())); + + // built-in non-plugin-consuming commands → short-circuit + assertFalse(PluginHelper.shouldDiscoverPlugins(cl, "version")); + assertFalse(PluginHelper.shouldDiscoverPlugins(cl, "get", "bean")); + + // plugin-consuming built-ins → must load + assertTrue(PluginHelper.shouldDiscoverPlugins(cl, "run", "foo.yaml")); + assertTrue(PluginHelper.shouldDiscoverPlugins(cl, "export")); + assertTrue(PluginHelper.shouldDiscoverPlugins(cl, "shell")); + assertTrue(PluginHelper.shouldDiscoverPlugins(cl, "cmd", "browse")); + + // unknown command (likely plugin-provided) → must load + assertTrue(PluginHelper.shouldDiscoverPlugins(cl, "kubernetes", "run")); + + // no args / help → must load so plugin commands appear in help listing + assertTrue(PluginHelper.shouldDiscoverPlugins(cl)); + assertTrue(PluginHelper.shouldDiscoverPlugins(cl, "--help")); + assertTrue(PluginHelper.shouldDiscoverPlugins(cl, "")); + } + + @Test + public void testCacheHitSkipsDownload() throws Exception { + Path jar = tempDir.resolve("fake-plugin.jar"); + FakePluginJar.write(jar, "fake"); + + String camelVersion = new org.apache.camel.catalog.DefaultCamelCatalog().getCatalogVersion(); + writeConfig(buildEntry("fake", camelVersion, jar, Files.getLastModifiedTime(jar).toMillis())); + + CamelJBangMain main = new CamelJBangMain(); + Map plugins = PluginHelper.getActivePlugins(main, null, "fake"); + assertEquals(1, plugins.size()); + assertNotNull(plugins.get("fake")); + // ensure the cache path returned an instance of the class loaded from the cached jar + assertEquals(FakePluginJar.PLUGIN_CLASS, plugins.get("fake").getClass().getName()); + } + + @Test + public void testCacheInvalidatedOnMtimeChange() throws Exception { + Path jar = tempDir.resolve("fake-plugin.jar"); + FakePluginJar.write(jar, "fake"); + + String camelVersion = new org.apache.camel.catalog.DefaultCamelCatalog().getCatalogVersion(); + long staleMtime = Files.getLastModifiedTime(jar).toMillis() - 1000; + writeConfig(buildEntry("fake", camelVersion, jar, staleMtime)); + + // Stale mtime invalidates the cache. There is no factory-finder entry and no Maven dependency to + // download (gav is empty), so the resolver returns empty and quits. + QuitCapture main = new QuitCapture(); + assertThrows(RuntimeException.class, () -> PluginHelper.getActivePlugins(main, null, "fake")); + assertTrue(main.quitCalled, "expected resolver to give up when cache is invalid and no download path is viable"); + } + + @Test + public void testWriteCachePersistsResolvedBlock() throws Exception { + Path jar = tempDir.resolve("camel-jbang-plugin-fake-9.9.9.jar"); + FakePluginJar.write(jar, "fake"); + Path pom = tempDir.resolve("camel-jbang-plugin-fake-9.9.9.pom"); + Files.writeString(pom, ""); + + JsonObject entry = new JsonObject(); + entry.put("name", "fake"); + entry.put("command", "fake"); + + try (java.net.URLClassLoader cl = new java.net.URLClassLoader(new java.net.URL[] { jar.toUri().toURL() })) { + boolean written = PluginHelper.writeCache(entry, "9.9.9", null, null, FakePluginJar.PLUGIN_CLASS, cl, "fake", + "9.9.9"); + assertTrue(written); + } + + JsonObject resolved = entry.getMap("resolved"); + assertNotNull(resolved); + assertEquals("9.9.9", resolved.getString("camelVersion")); + assertEquals(FakePluginJar.PLUGIN_CLASS, resolved.getString("className")); + assertNotNull(resolved.get("cachedAt")); + + Object cp = resolved.get("classpath"); + assertTrue(cp instanceof java.util.Collection); + java.util.Collection classpath = (java.util.Collection) cp; + assertEquals(1, classpath.size()); + Map jarEntry = (Map) classpath.iterator().next(); + assertEquals(jar.toAbsolutePath().toString(), jarEntry.get("path")); + assertEquals(Files.size(jar), ((Number) jarEntry.get("size")).longValue()); + assertEquals(Files.getLastModifiedTime(jar).toMillis(), ((Number) jarEntry.get("mtime")).longValue()); + + // POM sibling should be tracked since it lives next to the plugin jar + Map pomEntry = (Map) resolved.get("pom"); + assertNotNull(pomEntry); + assertEquals(pom.toAbsolutePath().toString(), pomEntry.get("path")); + assertEquals(Files.size(pom), ((Number) pomEntry.get("size")).longValue()); + } + + @Test + public void testCacheFastPathAvoidsResolver() throws Exception { + // Before/after demonstration of CAMEL-23335 cache fast path. Same plugin name and on-disk jar in + // both halves — only the presence of the `resolved` block in the config differs. + Path jar = tempDir.resolve("fake-plugin.jar"); + FakePluginJar.write(jar, "fake"); + String camelVersion = new org.apache.camel.catalog.DefaultCamelCatalog().getCatalogVersion(); + + // BEFORE CAMEL-23335: entry has no `resolved` block. resolvePlugin falls through loadFromCache, + // misses FACTORY_FINDER (no service registered for "fake"), reaches downloadPlugin with no usable + // gav, gets nothing back, and quits. + writeConfig(buildEntryWithoutResolvedBlock("fake", camelVersion)); + QuitCapture before = new QuitCapture(); + assertThrows(RuntimeException.class, () -> PluginHelper.getActivePlugins(before, null, "fake")); + assertTrue(before.quitCalled, + "without the resolved-block cache, resolver attempted download and gave up"); + + // AFTER CAMEL-23335: entry has a valid `resolved` block. loadFromCache builds a URLClassLoader + // from the cached jar and returns the plugin directly — FACTORY_FINDER and Maven are never touched. + writeConfig(buildEntry("fake", camelVersion, jar, Files.getLastModifiedTime(jar).toMillis())); + QuitCapture after = new QuitCapture(); + Map plugins + = assertDoesNotThrow(() -> PluginHelper.getActivePlugins(after, null, "fake")); + assertFalse(after.quitCalled, "cache fast path resolved the plugin without invoking the resolver"); + assertEquals(FakePluginJar.PLUGIN_CLASS, plugins.get("fake").getClass().getName(), + "plugin loaded from the cached jar, not via FACTORY_FINDER"); + } + + private void writeConfig(JsonObject pluginEntry) throws Exception { + JsonObject plugins = new JsonObject(); + plugins.put(pluginEntry.getString("name"), pluginEntry); + JsonObject config = new JsonObject(); + config.put("plugins", plugins); + Path userConfig = CommandLineHelper.getHomeDir().resolve(PluginHelper.PLUGIN_CONFIG); + Files.writeString(userConfig, config.toJson(), StandardOpenOption.CREATE); + } + + private static JsonObject buildEntryWithoutResolvedBlock(String name, String camelVersion) { + JsonObject entry = new JsonObject(); + entry.put("name", name); + entry.put("command", name); + entry.put("description", "Fake plugin"); + entry.put("firstVersion", camelVersion); + return entry; + } + + private static JsonObject buildEntry(String name, String camelVersion, Path jar, long jarMtime) throws Exception { + JsonObject entry = new JsonObject(); + entry.put("name", name); + entry.put("command", name); + entry.put("description", "Fake plugin"); + entry.put("firstVersion", camelVersion); + + JsonObject jarEntry = new JsonObject(); + jarEntry.put("path", jar.toAbsolutePath().toString()); + jarEntry.put("size", Files.size(jar)); + jarEntry.put("mtime", jarMtime); + + JsonObject resolved = new JsonObject(); + resolved.put("camelVersion", camelVersion); + resolved.put("className", FakePluginJar.PLUGIN_CLASS); + java.util.List cp = new java.util.ArrayList<>(); + cp.add(jarEntry); + resolved.put("classpath", cp); + entry.put("resolved", resolved); + return entry; + } + + private static class QuitCapture extends CamelJBangMain { + boolean quitCalled; + + @Override + public void quit(int exitCode) { + quitCalled = true; + throw new RuntimeException("quit"); + } + } + + @CommandLine.Command(name = "noop") + private static class NoOpCommand implements Runnable { + @Override + public void run() { + // no-op + } + } }