diff --git a/core/forage-core-common/src/main/java/io/kaoto/forage/core/common/ForageModuleDescriptor.java b/core/forage-core-common/src/main/java/io/kaoto/forage/core/common/ForageModuleDescriptor.java index 567d744df..514cdefb3 100644 --- a/core/forage-core-common/src/main/java/io/kaoto/forage/core/common/ForageModuleDescriptor.java +++ b/core/forage-core-common/src/main/java/io/kaoto/forage/core/common/ForageModuleDescriptor.java @@ -76,6 +76,15 @@ default Map translateProperties(String prefix, C config) { return Collections.emptyMap(); } + /** + * Translates forage properties for export-time use. By default delegates to + * {@link #translateProperties(String, Config)}, but subclasses can override to skip + * runtime-only checks (e.g., classpath verification) that don't apply at export time. + */ + default Map translatePropertiesForExport(String prefix, C config) { + return translateProperties(prefix, config); + } + /** * Returns auxiliary bean descriptors (e.g., aggregation repos, idempotent repos) * that should be created alongside the primary bean for the given prefix. diff --git a/library/ai/agents/forage-agent/src/main/java/io/kaoto/forage/agent/AgentModuleDescriptor.java b/library/ai/agents/forage-agent/src/main/java/io/kaoto/forage/agent/AgentModuleDescriptor.java index 878f08eec..47562cac5 100644 --- a/library/ai/agents/forage-agent/src/main/java/io/kaoto/forage/agent/AgentModuleDescriptor.java +++ b/library/ai/agents/forage-agent/src/main/java/io/kaoto/forage/agent/AgentModuleDescriptor.java @@ -83,6 +83,19 @@ public Map translateProperties(String prefix, AgentConfig config } } + return translateModelProperties(modelKind, config); + } + + @Override + public Map translatePropertiesForExport(String prefix, AgentConfig config) { + String modelKind = config.modelKind(); + if (modelKind == null) { + return Map.of(); + } + return translateModelProperties(modelKind, config); + } + + private Map translateModelProperties(String modelKind, AgentConfig config) { return switch (modelKind) { case "ollama" -> translateOllamaProperties(config); case "openai" -> translateOpenAiProperties(config); diff --git a/library/ai/agents/forage-agent/src/main/resources/META-INF/services/io.kaoto.forage.core.common.ForageModuleDescriptor b/library/ai/agents/forage-agent/src/main/resources/META-INF/services/io.kaoto.forage.core.common.ForageModuleDescriptor new file mode 100644 index 000000000..6411ecd6e --- /dev/null +++ b/library/ai/agents/forage-agent/src/main/resources/META-INF/services/io.kaoto.forage.core.common.ForageModuleDescriptor @@ -0,0 +1 @@ +io.kaoto.forage.agent.AgentModuleDescriptor diff --git a/library/cxf/forage-cxf-common/src/main/resources/META-INF/services/io.kaoto.forage.core.common.ForageModuleDescriptor b/library/cxf/forage-cxf-common/src/main/resources/META-INF/services/io.kaoto.forage.core.common.ForageModuleDescriptor new file mode 100644 index 000000000..b05dbd4cb --- /dev/null +++ b/library/cxf/forage-cxf-common/src/main/resources/META-INF/services/io.kaoto.forage.core.common.ForageModuleDescriptor @@ -0,0 +1 @@ +io.kaoto.forage.cxf.common.CxfModuleDescriptor diff --git a/library/jdbc/forage-jdbc-common/src/main/resources/META-INF/services/io.kaoto.forage.core.common.ForageModuleDescriptor b/library/jdbc/forage-jdbc-common/src/main/resources/META-INF/services/io.kaoto.forage.core.common.ForageModuleDescriptor new file mode 100644 index 000000000..57bac9bfe --- /dev/null +++ b/library/jdbc/forage-jdbc-common/src/main/resources/META-INF/services/io.kaoto.forage.core.common.ForageModuleDescriptor @@ -0,0 +1 @@ +io.kaoto.forage.jdbc.common.JdbcModuleDescriptor diff --git a/library/jms/forage-jms-common/src/main/resources/META-INF/services/io.kaoto.forage.core.common.ForageModuleDescriptor b/library/jms/forage-jms-common/src/main/resources/META-INF/services/io.kaoto.forage.core.common.ForageModuleDescriptor new file mode 100644 index 000000000..0d4f12f8d --- /dev/null +++ b/library/jms/forage-jms-common/src/main/resources/META-INF/services/io.kaoto.forage.core.common.ForageModuleDescriptor @@ -0,0 +1 @@ +io.kaoto.forage.jms.common.JmsModuleDescriptor diff --git a/library/messaging/forage-spring-rabbitmq-common/src/main/resources/META-INF/services/io.kaoto.forage.core.common.ForageModuleDescriptor b/library/messaging/forage-spring-rabbitmq-common/src/main/resources/META-INF/services/io.kaoto.forage.core.common.ForageModuleDescriptor new file mode 100644 index 000000000..457d0cfd6 --- /dev/null +++ b/library/messaging/forage-spring-rabbitmq-common/src/main/resources/META-INF/services/io.kaoto.forage.core.common.ForageModuleDescriptor @@ -0,0 +1 @@ +io.kaoto.forage.messaging.spring.rabbitmq.common.SpringRabbitMQModuleDescriptor diff --git a/tooling/camel-jbang-plugin-forage/pom.xml b/tooling/camel-jbang-plugin-forage/pom.xml index 39c7b2eb7..b6c5e1c57 100644 --- a/tooling/camel-jbang-plugin-forage/pom.xml +++ b/tooling/camel-jbang-plugin-forage/pom.xml @@ -38,6 +38,65 @@ ${project.version} + + + io.kaoto.forage + forage-jdbc-common + ${project.version} + + + * + * + + + + + io.kaoto.forage + forage-jms-common + ${project.version} + + + * + * + + + + + io.kaoto.forage + forage-agent + ${project.version} + + + * + * + + + + + io.kaoto.forage + forage-cxf-common + ${project.version} + + + * + * + + + + + io.kaoto.forage + forage-spring-rabbitmq-common + ${project.version} + + + * + * + + + + org.slf4j slf4j-api diff --git a/tooling/camel-jbang-plugin-forage/src/main/java/io/kaoto/forage/plugin/ForagePlugin.java b/tooling/camel-jbang-plugin-forage/src/main/java/io/kaoto/forage/plugin/ForagePlugin.java index 8f93e601f..3da7859f5 100644 --- a/tooling/camel-jbang-plugin-forage/src/main/java/io/kaoto/forage/plugin/ForagePlugin.java +++ b/tooling/camel-jbang-plugin-forage/src/main/java/io/kaoto/forage/plugin/ForagePlugin.java @@ -1,13 +1,17 @@ package io.kaoto.forage.plugin; +import java.io.File; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; import org.apache.camel.dsl.jbang.core.common.CamelJBangPlugin; +import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; import org.apache.camel.dsl.jbang.core.common.Plugin; import org.apache.camel.dsl.jbang.core.common.PluginExporter; import org.apache.camel.dsl.jbang.core.common.PluginRunCustomizer; @@ -18,6 +22,7 @@ import org.slf4j.LoggerFactory; import io.kaoto.forage.core.common.ExportCustomizer; import io.kaoto.forage.core.common.RuntimeType; +import io.kaoto.forage.core.util.config.ConfigStore; import io.kaoto.forage.plugin.config.ConfigCommand; import io.kaoto.forage.plugin.config.ConfigReadCommand; import io.kaoto.forage.plugin.config.ConfigWriteCommand; @@ -53,8 +58,14 @@ public Optional getRunCustomizer() { @Override public Optional getExporter() { return Optional.of(new PluginExporter() { + @Override public Set getDependencies(org.apache.camel.dsl.jbang.core.common.RuntimeType runtimeType) { + if (RuntimeType.quarkus.name().equals(runtimeType.name())) { + // mirrors ExportBaseCommand.BUILD_DIR (protected, not accessible from plugins) + Path buildDir = Path.of(CommandLineHelper.CAMEL_JBANG_WORK_DIR, "work"); + translateForageProperties(buildDir); + } // gather dependencies across all (enabled) export customizers for the specific runtime return ExportHelper.getAllCustomizers() .filter(ExportCustomizer::isEnabled) @@ -74,6 +85,88 @@ public void addSourceFiles(Path buildDir, String packageName, Printer printer) { }); } + private static void translateForageProperties(Path buildDir) { + String configDir = System.getProperty("forage.config.dir"); + File workingDir = configDir != null ? new File(configDir) : new File(System.getProperty("user.dir")); + + QuarkusPropertyTranslator.TranslationResult result; + try { + result = QuarkusPropertyTranslator.translate(workingDir); + } catch (IOException e) { + LOG.warn("Failed to scan forage properties for Quarkus translation: {}", e.getMessage()); + return; + } finally { + // translate() populates the global ConfigStore singleton via config.register() — clean up + // so scanned values don't leak into subsequent operations in the same JVM + ConfigStore.getInstance().reload(); + } + + if (result.isEmpty()) { + return; + } + + Path appPropsPath = buildDir.resolve("src/main/resources/application.properties"); + if (!Files.exists(appPropsPath)) { + LOG.warn("application.properties not found at {} — skipping property translation", appPropsPath); + return; + } + + try { + rewriteApplicationProperties(appPropsPath, result); + LOG.info("Translated {} forage properties to Quarkus-native format", result.propertyCount()); + } catch (IOException e) { + LOG.warn("Failed to rewrite application.properties: {}", e.getMessage()); + } + } + + static void rewriteApplicationProperties(Path appPropsPath, QuarkusPropertyTranslator.TranslationResult result) + throws IOException { + List originalLines = Files.readAllLines(appPropsPath); + Set keysToRemove = result.translatedForageKeys(); + + // Keep every line except those whose key was translated to Quarkus format + Stream filtered = originalLines.stream().filter(line -> { + String key = extractPropertyKey(line); + return key == null || !keysToRemove.contains(key); + }); + + // Append each translation group with a header showing the original forage properties + Stream translated = result.groups().stream().flatMap(ForagePlugin::translationGroupToLines); + + Files.write(appPropsPath, Stream.concat(filtered, translated).toList()); + } + + /** + * Extracts the property key from a .properties file line. + * Returns {@code null} for blank lines, comments, and lines without '='. + */ + private static String extractPropertyKey(String line) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("!")) { + return null; + } + int eqIdx = trimmed.indexOf('='); + return eqIdx < 0 ? null : trimmed.substring(0, eqIdx).trim(); + } + + private static Stream translationGroupToLines(QuarkusPropertyTranslator.TranslationGroup group) { + Stream.Builder lines = Stream.builder(); + lines.add(""); + if (group.prefix() != null) { + lines.add("# Properties for " + group.moduleType() + " with prefix '" + group.prefix() + "'"); + } else { + lines.add("# Properties for " + group.moduleType()); + } + lines.add("# Translated from:"); + group.sourceProperties().forEach((k, v) -> lines.add("# " + k + "=" + v)); + group.properties().forEach((k, v) -> { + if (v != null) { + lines.add(k + "=" + v); + } + }); + return lines.build(); + } + private static final String SHIBBOLETH_REPO = "https://build.shibboleth.net/maven/releases/"; private void beforeRun(KameletMain main, List files) { diff --git a/tooling/camel-jbang-plugin-forage/src/main/java/io/kaoto/forage/plugin/QuarkusPropertyTranslator.java b/tooling/camel-jbang-plugin-forage/src/main/java/io/kaoto/forage/plugin/QuarkusPropertyTranslator.java new file mode 100644 index 000000000..33abcf8a1 --- /dev/null +++ b/tooling/camel-jbang-plugin-forage/src/main/java/io/kaoto/forage/plugin/QuarkusPropertyTranslator.java @@ -0,0 +1,201 @@ +package io.kaoto.forage.plugin; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import io.kaoto.forage.catalog.reader.ForageCatalogReader; +import io.kaoto.forage.core.common.ForageModuleDescriptor; +import io.kaoto.forage.core.util.config.Config; +import io.kaoto.forage.core.util.config.ConfigStore; +import io.kaoto.forage.plugin.ForagePropertyScanner.PropertyOccurrence; + +/** + * Translates forage properties to Quarkus-native properties at export time. + * Uses existing {@link ForageModuleDescriptor#translatePropertiesForExport} logic + * by populating {@link ConfigStore} with scanned property values. + */ +public final class QuarkusPropertyTranslator { + + private static final Logger LOG = LoggerFactory.getLogger(QuarkusPropertyTranslator.class); + + private QuarkusPropertyTranslator() {} + + public record TranslationGroup( + String moduleType, String prefix, Map sourceProperties, Map properties) {} + + public record TranslationResult(List groups, Set translatedForageKeys) { + + public boolean isEmpty() { + return groups.isEmpty(); + } + + public int propertyCount() { + return groups.stream().mapToInt(g -> g.properties().size()).sum(); + } + } + + /** + * Translates forage properties found in the working directory to Quarkus-native properties. + * + * @param workingDir the directory containing route and properties files + * @return translation result with quarkus properties and the forage keys that were translated + */ + @SuppressWarnings("rawtypes") + public static TranslationResult translate(File workingDir) throws IOException { + // 1. Scan working directory for forage.* properties across all files + ForageCatalogReader catalog = ForageCatalogReader.getInstance(); + Map>> scanned = + ForagePropertyScanner.scanPropertiesWithFileTracking(workingDir, catalog, false); + + // 2. Discover available module descriptors (jdbc, agent, jms, ...) via ServiceLoader + Map descriptors = discoverDescriptors(); + + // 3. For each module type, group its properties by named prefix and translate + List groups = new ArrayList<>(); + Set translatedForageKeys = new LinkedHashSet<>(); + + for (Map.Entry>> factoryEntry : scanned.entrySet()) { + String factoryTypeKey = factoryEntry.getKey(); + + ForageModuleDescriptor descriptor = descriptors.get(factoryTypeKey); + if (descriptor == null) { + LOG.warn( + "No ForageModuleDescriptor for factory type '{}' — properties will not be translated to Quarkus format", + factoryTypeKey); + continue; + } + + // Group by named prefix, e.g. "ds1" for forage.ds1.jdbc.*, null for forage.jdbc.* + Map> byPrefix = + groupByPrefix(factoryEntry.getValue(), descriptor.modulePrefix()); + + for (Map.Entry> prefixEntry : byPrefix.entrySet()) { + String prefix = prefixEntry.getKey(); + Map props = prefixEntry.getValue(); + + TranslationGroup group = translatePrefixGroup(descriptor, factoryTypeKey, prefix, props); + if (group != null) { + groups.add(group); + translatedForageKeys.addAll(props.keySet()); + } + } + } + + return new TranslationResult(groups, translatedForageKeys); + } + + /** + * Translates a single prefix group's forage properties to Quarkus-native format. + * Returns {@code null} if translation produces no output or fails. + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private static TranslationGroup translatePrefixGroup( + ForageModuleDescriptor descriptor, String factoryTypeKey, String prefix, Map props) { + try { + Config config = descriptor.createConfig(prefix); + props.forEach(config::register); + + Map translated = descriptor.translatePropertiesForExport(prefix, config); + if (translated.isEmpty()) { + return null; + } + return new TranslationGroup(descriptor.modulePrefix(), prefix, props, translated); + } catch (Exception e) { + LOG.warn( + "Failed to translate properties for module '{}' prefix '{}': {}", + factoryTypeKey, + prefix, + e.getMessage()); + LOG.debug("Translation error details:", e); + return null; + } + } + + @SuppressWarnings("rawtypes") + private static Map discoverDescriptors() { + Map result = new HashMap<>(); + try { + ServiceLoader loader = + ServiceLoader.load(ForageModuleDescriptor.class, QuarkusPropertyTranslator.class.getClassLoader()); + for (ForageModuleDescriptor descriptor : loader) { + String prefix = descriptor.modulePrefix(); + result.put(prefix, descriptor); + LOG.debug( + "Discovered ForageModuleDescriptor: {} → {}", + prefix, + descriptor.getClass().getName()); + } + } catch (Exception e) { + LOG.warn("Error discovering ForageModuleDescriptors: {}", e.getMessage()); + LOG.debug("ServiceLoader error details:", e); + } + return result; + } + + /** + * Groups scanned properties by their named prefix. + * + *

For {@code "forage.ds1.jdbc.url=..."} with modulePrefix {@code "jdbc"}, + * the prefix is {@code "ds1"} and the full key {@code "forage.ds1.jdbc.url"} is preserved. + * + *

For {@code "forage.jdbc.url=..."} the prefix is {@code null}. + * + * @return map of prefix (nullable) → (fullPropertyName → value) + */ + private static Map> groupByPrefix( + Map> factoryProperties, String modulePrefix) { + Map> result = new LinkedHashMap<>(); + + for (List occurrences : factoryProperties.values()) { + for (PropertyOccurrence occ : occurrences) { + String prefix = extractNamedPrefix(occ.fullPropertyName(), modulePrefix); + result.computeIfAbsent(prefix, k -> new LinkedHashMap<>()).put(occ.fullPropertyName(), occ.value()); + } + } + + return result; + } + + /** + * Extracts the named prefix from a full forage property name. + * + *

Examples with modulePrefix {@code "jdbc"}: + *

    + *
  • {@code "forage.jdbc.url"} → {@code null} (default instance)
  • + *
  • {@code "forage.ds1.jdbc.url"} → {@code "ds1"}
  • + *
+ */ + // Property format: forage.[.]. + // e.g., forage.jdbc.url → null (default), forage.ds1.jdbc.url → "ds1" + static String extractNamedPrefix(String fullPropertyName, String modulePrefix) { + if (!fullPropertyName.startsWith("forage.")) { + return null; + } + String afterForage = fullPropertyName.substring("forage.".length()); + + // Default instance: starts directly with modulePrefix (e.g. "jdbc.url") + if (afterForage.startsWith(modulePrefix + ".") || afterForage.equals(modulePrefix)) { + return null; + } + + // Named instance: first segment is the prefix (e.g. "ds1" in "ds1.jdbc.url") + int firstDot = afterForage.indexOf('.'); + if (firstDot > 0) { + String remaining = afterForage.substring(firstDot + 1); + if (remaining.startsWith(modulePrefix + ".") || remaining.equals(modulePrefix)) { + return afterForage.substring(0, firstDot); + } + } + + return null; + } +} diff --git a/tooling/camel-jbang-plugin-forage/src/test/java/io/kaoto/forage/plugin/ForagePluginRewriteTest.java b/tooling/camel-jbang-plugin-forage/src/test/java/io/kaoto/forage/plugin/ForagePluginRewriteTest.java new file mode 100644 index 000000000..503b03404 --- /dev/null +++ b/tooling/camel-jbang-plugin-forage/src/test/java/io/kaoto/forage/plugin/ForagePluginRewriteTest.java @@ -0,0 +1,204 @@ +package io.kaoto.forage.plugin; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.assertj.core.api.Assertions.assertThat; + +class ForagePluginRewriteTest { + + @TempDir + Path tempDir; + + @Test + void forageKeysReplacedWithQuarkusProperties() throws IOException { + Path appProps = writeProps( + "# App config", + "server.port=8080", + "forage.jdbc.db.kind=postgresql", + "forage.jdbc.url=jdbc:postgresql://localhost:5432/mydb", + "forage.jdbc.username=admin", + "forage.jdbc.password=secret", + "camel.rest.port=9090"); + + ForagePlugin.rewriteApplicationProperties( + appProps, + result( + group( + "jdbc", + null, + Map.of( + "forage.jdbc.db.kind", + "postgresql", + "forage.jdbc.url", + "jdbc:postgresql://localhost:5432/mydb", + "forage.jdbc.username", + "admin", + "forage.jdbc.password", + "secret"), + Map.of( + "quarkus.datasource.\"dataSource\".db-kind", + "postgresql", + "quarkus.datasource.\"dataSource\".jdbc.url", + "jdbc:postgresql://localhost:5432/mydb", + "quarkus.datasource.\"dataSource\".username", + "admin", + "quarkus.datasource.\"dataSource\".password", + "secret")), + Set.of( + "forage.jdbc.db.kind", + "forage.jdbc.url", + "forage.jdbc.username", + "forage.jdbc.password"))); + + List lines = Files.readAllLines(appProps); + + assertThat(lines).contains("# App config", "server.port=8080", "camel.rest.port=9090"); + assertThat(lines).noneMatch(l -> l.startsWith("forage.")); + assertThat(lines).anyMatch(l -> l.contains("quarkus.datasource.\"dataSource\".db-kind=postgresql")); + assertThat(lines).anyMatch(l -> l.contains("quarkus.datasource.\"dataSource\".username=admin")); + } + + @Test + void namedPrefixesProduceGroupComments() throws IOException { + Path appProps = writeProps("forage.ds1.jdbc.db.kind=postgresql", "forage.ds2.jdbc.db.kind=mysql"); + + ForagePlugin.rewriteApplicationProperties( + appProps, + result( + List.of( + group( + "jdbc", + "ds1", + Map.of("forage.ds1.jdbc.db.kind", "postgresql"), + Map.of("quarkus.datasource.\"ds1\".db-kind", "postgresql")), + group( + "jdbc", + "ds2", + Map.of("forage.ds2.jdbc.db.kind", "mysql"), + Map.of("quarkus.datasource.\"ds2\".db-kind", "mysql"))), + Set.of("forage.ds1.jdbc.db.kind", "forage.ds2.jdbc.db.kind"))); + + List lines = Files.readAllLines(appProps); + + assertThat(lines).contains("# Properties for jdbc with prefix 'ds1'"); + assertThat(lines).contains("# Properties for jdbc with prefix 'ds2'"); + assertThat(lines).noneMatch(l -> l.startsWith("forage.")); + } + + @Test + void defaultPrefixCommentOmitsPrefixLabel() throws IOException { + Path appProps = writeProps("forage.jdbc.db.kind=postgresql"); + + ForagePlugin.rewriteApplicationProperties( + appProps, + result( + group( + "jdbc", + null, + Map.of("forage.jdbc.db.kind", "postgresql"), + Map.of("quarkus.datasource.\"dataSource\".db-kind", "postgresql")), + Set.of("forage.jdbc.db.kind"))); + + List lines = Files.readAllLines(appProps); + + assertThat(lines).contains("# Properties for jdbc"); + assertThat(lines).noneMatch(l -> l.contains("with prefix")); + } + + @Test + void sourcePropertiesShownAsComments() throws IOException { + Path appProps = writeProps("forage.jdbc.db.kind=postgresql", "forage.jdbc.username=admin"); + + ForagePlugin.rewriteApplicationProperties( + appProps, + result( + group( + "jdbc", + null, + Map.of("forage.jdbc.db.kind", "postgresql", "forage.jdbc.username", "admin"), + Map.of( + "quarkus.datasource.\"dataSource\".db-kind", + "postgresql", + "quarkus.datasource.\"dataSource\".username", + "admin")), + Set.of("forage.jdbc.db.kind", "forage.jdbc.username"))); + + List lines = Files.readAllLines(appProps); + + assertThat(lines).contains("# Translated from:"); + assertThat(lines).anyMatch(l -> l.equals("# forage.jdbc.db.kind=postgresql")); + assertThat(lines).anyMatch(l -> l.equals("# forage.jdbc.username=admin")); + } + + @Test + void commentsAndBlankLinesPreserved() throws IOException { + Path appProps = writeProps( + "# My application", "", "! legacy comment", "forage.jdbc.db.kind=postgresql", "", "other.prop=value"); + + ForagePlugin.rewriteApplicationProperties( + appProps, + result( + group( + "jdbc", + null, + Map.of("forage.jdbc.db.kind", "postgresql"), + Map.of("quarkus.datasource.\"dataSource\".db-kind", "postgresql")), + Set.of("forage.jdbc.db.kind"))); + + List lines = Files.readAllLines(appProps); + + assertThat(lines).contains("# My application", "", "! legacy comment", "other.prop=value"); + assertThat(lines).noneMatch(l -> l.equals("forage.jdbc.db.kind=postgresql")); + } + + @Test + void nonTranslatedForageKeysKept() throws IOException { + Path appProps = writeProps("forage.jdbc.db.kind=postgresql", "forage.cxf.kind=soap"); + + ForagePlugin.rewriteApplicationProperties( + appProps, + result( + group( + "jdbc", + null, + Map.of("forage.jdbc.db.kind", "postgresql"), + Map.of("quarkus.datasource.\"dataSource\".db-kind", "postgresql")), + Set.of("forage.jdbc.db.kind"))); + + List lines = Files.readAllLines(appProps); + + assertThat(lines).anyMatch(l -> l.equals("forage.cxf.kind=soap")); + assertThat(lines).noneMatch(l -> l.equals("forage.jdbc.db.kind=postgresql")); + } + + private Path writeProps(String... lines) throws IOException { + Path path = tempDir.resolve("application.properties"); + Files.write(path, List.of(lines)); + return path; + } + + private static QuarkusPropertyTranslator.TranslationGroup group( + String moduleType, String prefix, Map source, Map translated) { + return new QuarkusPropertyTranslator.TranslationGroup( + moduleType, prefix, new LinkedHashMap<>(source), new LinkedHashMap<>(translated)); + } + + private static QuarkusPropertyTranslator.TranslationResult result( + QuarkusPropertyTranslator.TranslationGroup group, Set translatedKeys) { + return new QuarkusPropertyTranslator.TranslationResult(List.of(group), new LinkedHashSet<>(translatedKeys)); + } + + private static QuarkusPropertyTranslator.TranslationResult result( + List groups, Set translatedKeys) { + return new QuarkusPropertyTranslator.TranslationResult(groups, new LinkedHashSet<>(translatedKeys)); + } +} diff --git a/tooling/camel-jbang-plugin-forage/src/test/java/io/kaoto/forage/plugin/QuarkusPropertyTranslatorTest.java b/tooling/camel-jbang-plugin-forage/src/test/java/io/kaoto/forage/plugin/QuarkusPropertyTranslatorTest.java new file mode 100644 index 000000000..73a867704 --- /dev/null +++ b/tooling/camel-jbang-plugin-forage/src/test/java/io/kaoto/forage/plugin/QuarkusPropertyTranslatorTest.java @@ -0,0 +1,213 @@ +package io.kaoto.forage.plugin; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import io.kaoto.forage.core.util.config.ConfigStore; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.assertj.core.api.Assertions.assertThat; + +class QuarkusPropertyTranslatorTest { + + @TempDir + Path tempDir; + + @AfterEach + void cleanUp() { + ConfigStore.getInstance().reload(); + } + + @Nested + class ExtractPrefixTest { + + @Test + void defaultInstance() { + assertThat(QuarkusPropertyTranslator.extractNamedPrefix("forage.jdbc.url", "jdbc")) + .isNull(); + } + + @Test + void namedInstance() { + assertThat(QuarkusPropertyTranslator.extractNamedPrefix("forage.ds1.jdbc.url", "jdbc")) + .isEqualTo("ds1"); + } + + @Test + void modulePrefixOnly() { + assertThat(QuarkusPropertyTranslator.extractNamedPrefix("forage.jdbc", "jdbc")) + .isNull(); + } + + @Test + void namedInstanceModulePrefixOnly() { + assertThat(QuarkusPropertyTranslator.extractNamedPrefix("forage.ds1.jdbc", "jdbc")) + .isEqualTo("ds1"); + } + + @Test + void multiSegmentModulePrefix() { + assertThat(QuarkusPropertyTranslator.extractNamedPrefix("forage.spring.rabbitmq.host", "spring.rabbitmq")) + .isNull(); + } + + @Test + void multiSegmentModulePrefixWithNamedInstance() { + assertThat(QuarkusPropertyTranslator.extractNamedPrefix( + "forage.mq1.spring.rabbitmq.host", "spring.rabbitmq")) + .isEqualTo("mq1"); + } + + @Test + void nonForageProperty() { + assertThat(QuarkusPropertyTranslator.extractNamedPrefix("quarkus.datasource.url", "jdbc")) + .isNull(); + } + + @Test + void noMatchingModulePrefix() { + assertThat(QuarkusPropertyTranslator.extractNamedPrefix("forage.unknown.property", "jdbc")) + .isNull(); + } + } + + @Nested + class TranslateTest { + + @Test + void jdbcPropertiesTranslated() throws IOException { + createPropertiesFile( + "application.properties", + "forage.jdbc.db.kind=postgresql", + "forage.jdbc.url=jdbc:postgresql://localhost:5432/mydb", + "forage.jdbc.username=admin", + "forage.jdbc.password=secret"); + + QuarkusPropertyTranslator.TranslationResult result = QuarkusPropertyTranslator.translate(tempDir.toFile()); + + assertThat(result.isEmpty()).isFalse(); + Map qProps = flattenGroups(result); + assertThat(qProps).containsEntry("quarkus.datasource.\"dataSource\".db-kind", "postgresql"); + assertThat(qProps) + .containsEntry( + "quarkus.datasource.\"dataSource\".jdbc.url", "jdbc:postgresql://localhost:5432/mydb"); + assertThat(qProps).containsEntry("quarkus.datasource.\"dataSource\".username", "admin"); + assertThat(qProps).containsEntry("quarkus.datasource.\"dataSource\".password", "secret"); + + Set translatedKeys = result.translatedForageKeys(); + assertThat(translatedKeys) + .contains("forage.jdbc.db.kind", "forage.jdbc.url", "forage.jdbc.username", "forage.jdbc.password"); + } + + @Test + void namedJdbcInstancesTranslated() throws IOException { + createPropertiesFile( + "application.properties", + "forage.jdbc.name=ds1,ds2", + "forage.ds1.jdbc.db.kind=postgresql", + "forage.ds1.jdbc.url=jdbc:postgresql://localhost/db1", + "forage.ds1.jdbc.username=user1", + "forage.ds1.jdbc.password=pass1", + "forage.ds2.jdbc.db.kind=mysql", + "forage.ds2.jdbc.url=jdbc:mysql://localhost/db2", + "forage.ds2.jdbc.username=user2", + "forage.ds2.jdbc.password=pass2"); + + QuarkusPropertyTranslator.TranslationResult result = QuarkusPropertyTranslator.translate(tempDir.toFile()); + + Map qProps = flattenGroups(result); + assertThat(qProps).containsEntry("quarkus.datasource.\"ds1\".db-kind", "postgresql"); + assertThat(qProps).containsEntry("quarkus.datasource.\"ds2\".db-kind", "mysql"); + + assertThat(result.groups()) + .extracting(QuarkusPropertyTranslator.TranslationGroup::moduleType) + .containsOnly("jdbc"); + assertThat(result.groups()) + .extracting(QuarkusPropertyTranslator.TranslationGroup::prefix) + .contains("ds1", "ds2"); + } + + @Test + void ollamaAgentPropertiesTranslated() throws IOException { + createPropertiesFile( + "application.properties", + "forage.agent.model.kind=ollama", + "forage.agent.base.url=http://localhost:11434", + "forage.agent.model.name=llama3"); + + QuarkusPropertyTranslator.TranslationResult result = QuarkusPropertyTranslator.translate(tempDir.toFile()); + + assertThat(result.isEmpty()).isFalse(); + Map qProps = flattenGroups(result); + assertThat(qProps).containsEntry("quarkus.langchain4j.ollama.base-url", "http://localhost:11434"); + assertThat(qProps).containsEntry("quarkus.langchain4j.ollama.chat-model.model-id", "llama3"); + + assertThat(result.groups()) + .extracting(QuarkusPropertyTranslator.TranslationGroup::moduleType) + .containsOnly("agent"); + } + + @Test + void anthropicAgentPropertiesTranslated() throws IOException { + createPropertiesFile( + "application.properties", + "forage.agent.model.kind=anthropic", + "forage.agent.api.key=sk-test-key", + "forage.agent.model.name=claude-sonnet-4-20250514", + "forage.agent.temperature=0.7"); + + QuarkusPropertyTranslator.TranslationResult result = QuarkusPropertyTranslator.translate(tempDir.toFile()); + + assertThat(result.isEmpty()).isFalse(); + Map qProps = flattenGroups(result); + assertThat(qProps).containsEntry("quarkus.langchain4j.anthropic.api-key", "sk-test-key"); + assertThat(qProps) + .containsEntry("quarkus.langchain4j.anthropic.chat-model.model-name", "claude-sonnet-4-20250514"); + assertThat(qProps).containsEntry("quarkus.langchain4j.anthropic.chat-model.temperature", "0.7"); + } + + @Test + void emptyDirectoryProducesEmptyResult() throws IOException { + QuarkusPropertyTranslator.TranslationResult result = QuarkusPropertyTranslator.translate(tempDir.toFile()); + + assertThat(result.isEmpty()).isTrue(); + assertThat(result.translatedForageKeys()).isEmpty(); + } + + @Test + void nonForagePropertiesIgnored() throws IOException { + createPropertiesFile( + "application.properties", "server.port=8080", "quarkus.datasource.url=jdbc:h2:mem:test"); + + QuarkusPropertyTranslator.TranslationResult result = QuarkusPropertyTranslator.translate(tempDir.toFile()); + + assertThat(result.isEmpty()).isTrue(); + assertThat(result.translatedForageKeys()).isEmpty(); + } + } + + private static Map flattenGroups(QuarkusPropertyTranslator.TranslationResult result) { + Map flat = new LinkedHashMap<>(); + for (QuarkusPropertyTranslator.TranslationGroup group : result.groups()) { + flat.putAll(group.properties()); + } + return flat; + } + + private File createPropertiesFile(String filename, String... lines) throws IOException { + File file = tempDir.resolve(filename).toFile(); + try (FileWriter writer = new FileWriter(file)) { + for (String line : lines) { + writer.write(line + "\n"); + } + } + return file; + } +}