This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 1def6015b768 CAMEL-23335: camel-jbang - Lazy plugin discovery and
resolved-classpath cache (#23129)
1def6015b768 is described below
commit 1def6015b768272a3837fa30f0bc45c252660b72
Author: Adriano Machado <[email protected]>
AuthorDate: Wed May 13 06:12:14 2026 -0400
CAMEL-23335: camel-jbang - Lazy plugin discovery and resolved-classpath
cache (#23129)
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 <[email protected]>
rh-pre-commit.version: 2.3.2
rh-pre-commit.check-secrets: ENABLED
Co-authored-by: Claus Ibsen <[email protected]>
---
.../ROOT/pages/camel-4x-upgrade-guide-4_21.adoc | 17 ++
.../dsl/jbang/core/commands/CamelJBangMain.java | 2 +-
.../camel/dsl/jbang/core/common/PluginHelper.java | 281 ++++++++++++++++++++-
.../dsl/jbang/core/common/CachedFakePlugin.java | 32 +++
.../camel/dsl/jbang/core/common/FakePluginJar.java | 59 +++++
.../dsl/jbang/core/common/PluginHelperTest.java | 190 ++++++++++++++
6 files changed, 568 insertions(+), 13 deletions(-)
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 d3deb55028d1..d3b12a318ea0 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
@@ -58,6 +58,23 @@ auto-disables `contentCache` on resource-based components
(such as `xslt`) whose
the route. Set `camel.component.<name>.contentCache=true` (or pass
`?contentCache=true` on the
URI) to opt back in to caching during dev mode.
+==== camel-jbang plugins
+
+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 d96725ab5749..f766d4ce91c2 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
@@ -214,7 +214,7 @@ public class CamelJBangMain implements Callable<Integer> {
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 0ce0100bb7e0..36b01f5e3eda 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 @@ package org.apache.camel.dsl.jbang.core.common;
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<String> 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 @@ public final class 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 final class PluginHelper {
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 final class PluginHelper {
versionCheck(main, version, firstVersion, command);
}
- Optional<Plugin> 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<Plugin> 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<Plugin> cached = loadFromCache(entry, defaultVersion, gav,
repos);
+ if (cached.isPresent()) {
+ return new ResolveResult(cached, false);
+ }
+
Optional<Plugin> 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<Plugin> 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<URL> 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<JsonObject> 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> plugin, boolean
cacheWritten) {
+ }
- return plugin;
+ private record DownloadResult(Optional<Plugin> plugin, ClassLoader
classLoader, String className) {
}
private static MavenGav dependencyAsMavenGav(String gav) {
@@ -227,7 +483,7 @@ public final class PluginHelper {
}
}
- private static Optional<Plugin> 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 @@ public final class PluginHelper {
downloader.downloadDependencyWithParent("org.apache.camel:camel-jbang-parent:pom:"
+ camelVersion, group,
"camel-jbang-plugin-" + command, version);
Optional<Plugin> 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 @@ public final class PluginHelper {
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 @@ public final class PluginHelper {
}
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 000000000000..46dd6491ff09
--- /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 000000000000..08e85baeb288
--- /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 0a1b9db38ff9..c9a0255953d2 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.apache.camel.util.json.JsonObject;
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 class PluginHelperTest {
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<String, Plugin> 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, "<project/>");
+
+ 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<String, Plugin> 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<JsonObject> 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
+ }
+ }
}