diff --git a/README.md b/README.md index 0ec0597..ada71d4 100644 --- a/README.md +++ b/README.md @@ -6,29 +6,48 @@ Solr container. ## Configuration -The devservice is enabled by default in development mode. However, the name of the core the service should create in the -Solr instance needs to be set and the Solr version can be configured in your `application.properties`: +The extension is opt-in. Enable it in `application.properties` when the application should inject Solr clients: + +```properties +quarkus.solr.enabled=true +``` + +For a single-core Dev Service, set the core name. The extension expects the Solr core config in the `solr` directory on +the application classpath: ```properties -# enabled by default quarkus.solr.devservices.enabled=true -# required quarkus.solr.devservices.core= -# default version is the latest stable one quarkus.solr.devservices.version=9.6.1 ``` -Also, the extension expects the configuration for the Solr core in the `solr` directory in the resources of your -project. +For multi-core Dev Services, configure one classpath config directory per core: + +```properties +quarkus.solr.devservices.cores.repositem.config-path=se/repos/indexing/solr/repositem +quarkus.solr.devservices.cores.reposxml.config-path=se/simonsoft/cms/indexing/xml/solr/reposxml +``` + +Each configured core can then be injected as a named client: + +```java +@Inject +@Named("repositem") +SolrClient repositem; + +@Inject +@Named("reposxml") +SolrClient reposxml; +``` ### Prod For production usage the configurations above do not matter. Only the URL of the Solr instance to connect to needs to be -provided. Optionally the SolrClient can be disabled (it's enabled by default): +provided: ```properties +quarkus.solr.enabled=true quarkus.solr.url=https://mydomain.fun/solr/mycore -quarkus.solr.enabled=false quarkus.solr.request-timeout=0 quarkus.solr.idle-timeout=60000 quarkus.solr.connection-timeout=60000 @@ -42,6 +61,14 @@ quarkus.solr.use-http1-1=false > In development mode this URL configuration is done automatically and filled with the host, port and core name of the > devservice container +For multiple external cores, configure named clients directly: + +```properties +quarkus.solr.enabled=true +quarkus.solr.clients.repositem.url=https://mydomain.fun/solr/repositem +quarkus.solr.clients.reposxml.url=https://mydomain.fun/solr/reposxml +``` + ## Usage The extension comes with a provider for a `SolrClient` bean, which is connected to the configured Solr diff --git a/deployment/pom.xml b/deployment/pom.xml index d50a4b6..219c238 100644 --- a/deployment/pom.xml +++ b/deployment/pom.xml @@ -27,7 +27,18 @@ org.testcontainers testcontainers - 1.20.0 + 2.0.4 + + + + com.github.docker-java + docker-java-api + 3.7.1 + + + com.github.docker-java + docker-java-transport-zerodep + 3.7.1 io.quarkus diff --git a/deployment/src/main/java/com/fntsoftware/solr/deployment/SolrProcessor.java b/deployment/src/main/java/com/fntsoftware/solr/deployment/SolrProcessor.java index e7f14e8..3769b54 100644 --- a/deployment/src/main/java/com/fntsoftware/solr/deployment/SolrProcessor.java +++ b/deployment/src/main/java/com/fntsoftware/solr/deployment/SolrProcessor.java @@ -1,20 +1,35 @@ package com.fntsoftware.solr.deployment; import com.fntsoftware.solr.runtime.SolrClientProducer; +import com.fntsoftware.solr.runtime.SolrClientRegistry; import com.fntsoftware.solr.runtime.SolrDevserviceConfig; +import com.fntsoftware.solr.runtime.NamedSolrClientCreator; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.deployment.IsNormal; import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.builditem.CuratedApplicationShutdownBuildItem; import io.quarkus.deployment.builditem.DevServicesResultBuildItem; import io.quarkus.runtime.LaunchMode; +import jakarta.inject.Singleton; +import org.apache.solr.client.solrj.SolrClient; +import org.eclipse.microprofile.config.Config; import org.eclipse.microprofile.config.ConfigProvider; +import org.jboss.jandex.DotName; +import org.jboss.jandex.Type; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategy; import org.testcontainers.images.builder.ImageFromDockerfile; import org.testcontainers.images.builder.dockerfile.statement.MultiArgsStatement; import java.io.IOException; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.TreeMap; import java.util.function.BooleanSupplier; class SolrProcessor { @@ -22,15 +37,39 @@ class SolrProcessor { static volatile DevServicesResultBuildItem.RunningDevService devService; private static final String FEATURE = "solr"; + private static final String CLIENT_PREFIX = "quarkus.solr.clients."; + private static final String CLIENT_URL_SUFFIX = ".url"; + private static final String DEV_SERVICE_CORE_PREFIX = "quarkus.solr.devservices.cores."; + private static final String DEV_SERVICE_CORE_CONFIG_PATH_SUFFIX = ".config-path"; + private static final Type SOLR_CLIENT_REGISTRY_TYPE = + Type.create(DotName.createSimple(SolrClientRegistry.class.getName()), Type.Kind.CLASS); @BuildStep public AdditionalBeanBuildItem producer() { return AdditionalBeanBuildItem.builder() .addBeanClass(SolrClientProducer.class) + .addBeanClass(SolrClientRegistry.class) .setUnremovable() .build(); } + @BuildStep + public void namedSolrClients(BuildProducer syntheticBeans) { + if (!solrEnabled()) { + return; + } + for (String name : configuredNamedClients()) { + syntheticBeans.produce(SyntheticBeanBuildItem.configure(SolrClient.class) + .scope(Singleton.class) + .named(name) + .unremovable() + .addInjectionPoint(SOLR_CLIENT_REGISTRY_TYPE) + .creator(NamedSolrClientCreator.class) + .param(NamedSolrClientCreator.NAME_PARAM, name) + .done()); + } + } + @BuildStep(onlyIfNot = IsNormal.class, onlyIf = WantsSolrDevService.class) public DevServicesResultBuildItem createContainer(CuratedApplicationShutdownBuildItem closeBuildItem) { if (devService != null) { @@ -47,15 +86,50 @@ public DevServicesResultBuildItem createContainer(CuratedApplicationShutdownBuil devService = null; }; closeBuildItem.addCloseTask(closeTask, false); + + Map cores = new TreeMap<>(config.cores()); + if (!cores.isEmpty()) { + return createMultiCoreContainer(cores); + } + return createSingleCoreContainer(); + } + + private DevServicesResultBuildItem createSingleCoreContainer() { + String core = config.core().orElseThrow( + () -> new IllegalStateException("quarkus.solr.devservices.core is required for single-core Solr Dev Service")); ImageFromDockerfile image = new ImageFromDockerfile("quarkus/devservices/solr") .withFileFromClasspath(".", "solr").withDockerfileFromBuilder(builder -> { builder.from("solr:" + config.version()).withStatement( - new MultiArgsStatement("COPY --chown=solr:solr", ".", "/var/solr/data/" + config.core())); + new MultiArgsStatement("COPY --chown=solr:solr", ".", "/var/solr/data/" + core)); }); - SolrContainer container = new SolrContainer(image); + SolrContainer container = new SolrContainer(image, Set.of(core)); container.start(); Map props = Map.of("quarkus.solr.url", "http://" + container.getHost() + ":" - + container.getMappedPort(container.getPort()) + "/solr/" + config.core()); + + container.getMappedPort(container.getPort()) + "/solr/" + core); + devService = new DevServicesResultBuildItem.RunningDevService(FEATURE, container.getContainerId(), + container::close, props); + return devService.toBuildItem(); + } + + private DevServicesResultBuildItem createMultiCoreContainer(Map cores) { + ImageFromDockerfile image = new ImageFromDockerfile("quarkus/devservices/solr"); + for (Map.Entry core : cores.entrySet()) { + image.withFileFromClasspath(core.getKey(), core.getValue().configPath()); + } + image.withDockerfileFromBuilder(builder -> { + builder.from("solr:" + config.version()); + for (String core : cores.keySet()) { + builder.withStatement(new MultiArgsStatement( + "COPY --chown=solr:solr", core, "/var/solr/data/" + core)); + } + }); + + SolrContainer container = new SolrContainer(image, cores.keySet()); + container.start(); + Map props = new LinkedHashMap<>(); + for (String core : cores.keySet()) { + props.put(CLIENT_PREFIX + core + CLIENT_URL_SUFFIX, solrCoreUrl(container, core)); + } devService = new DevServicesResultBuildItem.RunningDevService(FEATURE, container.getContainerId(), container::close, props); return devService.toBuildItem(); @@ -68,15 +142,17 @@ static class WantsSolrDevService implements BooleanSupplier { public boolean getAsBoolean() { Boolean devServicesActive = ConfigProvider.getConfig().getValue("quarkus.devservices.enabled", Boolean.class); - return launchMode.isDevOrTest() && devServicesActive && config.enabled(); + return launchMode.isDevOrTest() && solrEnabled() && devServicesActive && config.enabled(); } } private static class SolrContainer extends GenericContainer { static final int PORT = 8983; + private final Set cores; - public SolrContainer(ImageFromDockerfile image) { + public SolrContainer(ImageFromDockerfile image, Set cores) { super(image); + this.cores = Set.copyOf(cores); } public int getPort() { @@ -87,7 +163,43 @@ public int getPort() { protected void configure() { super.configure(); addExposedPort(PORT); - waitingFor(Wait.forLogMessage(".*Started Server.*", 1)); + waitingFor(waitForCores(cores)); + } + } + + private static WaitStrategy waitForCores(Set cores) { + WaitAllStrategy wait = new WaitAllStrategy(); + for (String core : cores) { + wait.withStrategy(Wait.forHttp("/solr/" + core + "/admin/ping") + .forPort(SolrContainer.PORT) + .forStatusCode(200)); + } + return wait; + } + + private static boolean solrEnabled() { + return ConfigProvider.getConfig().getOptionalValue("quarkus.solr.enabled", Boolean.class).orElse(false); + } + + private static Set configuredNamedClients() { + Config config = ConfigProvider.getConfig(); + Set names = new TreeSet<>(); + for (String propertyName : config.getPropertyNames()) { + clientName(propertyName, CLIENT_PREFIX, CLIENT_URL_SUFFIX).ifPresent(names::add); + clientName(propertyName, DEV_SERVICE_CORE_PREFIX, DEV_SERVICE_CORE_CONFIG_PATH_SUFFIX).ifPresent(names::add); + } + return names; + } + + private static java.util.Optional clientName(String propertyName, String prefix, String suffix) { + if (!propertyName.startsWith(prefix) || !propertyName.endsWith(suffix)) { + return java.util.Optional.empty(); } + String name = propertyName.substring(prefix.length(), propertyName.length() - suffix.length()); + return name.isBlank() ? java.util.Optional.empty() : java.util.Optional.of(name); + } + + private static String solrCoreUrl(SolrContainer container, String core) { + return "http://" + container.getHost() + ":" + container.getMappedPort(container.getPort()) + "/solr/" + core; } } diff --git a/deployment/src/test/java/com/fntsoftware/solr/deployment/SolrMultiCoreDevServiceTest.java b/deployment/src/test/java/com/fntsoftware/solr/deployment/SolrMultiCoreDevServiceTest.java new file mode 100644 index 0000000..4f581eb --- /dev/null +++ b/deployment/src/test/java/com/fntsoftware/solr/deployment/SolrMultiCoreDevServiceTest.java @@ -0,0 +1,72 @@ +package com.fntsoftware.solr.deployment; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.quarkus.test.QuarkusUnitTest; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import org.apache.solr.client.solrj.SolrClient; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class SolrMultiCoreDevServiceTest { + + private static final String SOLRCONFIG = """ + + 9.6.1 + + + + + + + *:* + + + + """; + + private static final String SCHEMA = """ + + + + id + + """; + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addAsResource(new StringAsset(""" + quarkus.solr.enabled=true + quarkus.solr.devservices.cores.repositem.config-path=solr/repositem + quarkus.solr.devservices.cores.reposxml.config-path=solr/reposxml + """), "application.properties") + .addAsResource(new StringAsset(""" + name=repositem + """), "solr/repositem/core.properties") + .addAsResource(new StringAsset(SOLRCONFIG), "solr/repositem/conf/solrconfig.xml") + .addAsResource(new StringAsset(SCHEMA), "solr/repositem/conf/schema.xml") + .addAsResource(new StringAsset(""" + name=reposxml + """), "solr/reposxml/core.properties") + .addAsResource(new StringAsset(SOLRCONFIG), "solr/reposxml/conf/solrconfig.xml") + .addAsResource(new StringAsset(SCHEMA), "solr/reposxml/conf/schema.xml")); + + @Inject + @Named("repositem") + SolrClient repositem; + + @Inject + @Named("reposxml") + SolrClient reposxml; + + @Test + void shouldStartSolrWithMultipleCores() throws Exception { + assertEquals(0, repositem.ping().getStatus()); + assertEquals(0, reposxml.ping().getStatus()); + } +} diff --git a/deployment/src/test/java/com/fntsoftware/solr/deployment/SolrNamedClientTest.java b/deployment/src/test/java/com/fntsoftware/solr/deployment/SolrNamedClientTest.java new file mode 100644 index 0000000..b7cd628 --- /dev/null +++ b/deployment/src/test/java/com/fntsoftware/solr/deployment/SolrNamedClientTest.java @@ -0,0 +1,41 @@ +package com.fntsoftware.solr.deployment; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.quarkus.test.QuarkusUnitTest; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import org.apache.solr.client.solrj.SolrClient; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class SolrNamedClientTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addAsResource(new StringAsset(""" + quarkus.solr.enabled=true + quarkus.solr.devservices.enabled=false + quarkus.solr.clients.repositem.url=http://localhost:1/solr/repositem + quarkus.solr.clients.reposxml.url=http://localhost:1/solr/reposxml + """), "application.properties")); + + @Inject + @Named("repositem") + Instance repositem; + + @Inject + @Named("reposxml") + Instance reposxml; + + @Test + void shouldResolveNamedSolrClients() { + assertTrue(repositem.isResolvable()); + assertTrue(reposxml.isResolvable()); + } +} diff --git a/runtime/src/main/java/com/fntsoftware/solr/runtime/NamedSolrClientCreator.java b/runtime/src/main/java/com/fntsoftware/solr/runtime/NamedSolrClientCreator.java new file mode 100644 index 0000000..01120a2 --- /dev/null +++ b/runtime/src/main/java/com/fntsoftware/solr/runtime/NamedSolrClientCreator.java @@ -0,0 +1,16 @@ +package com.fntsoftware.solr.runtime; + +import io.quarkus.arc.BeanCreator; +import io.quarkus.arc.SyntheticCreationalContext; +import org.apache.solr.client.solrj.SolrClient; + +public class NamedSolrClientCreator implements BeanCreator { + + public static final String NAME_PARAM = "name"; + + @Override + public SolrClient create(SyntheticCreationalContext context) { + String name = (String) context.getParams().get(NAME_PARAM); + return context.getInjectedReference(SolrClientRegistry.class).namedClientForBean(name); + } +} diff --git a/runtime/src/main/java/com/fntsoftware/solr/runtime/SolrClientProducer.java b/runtime/src/main/java/com/fntsoftware/solr/runtime/SolrClientProducer.java index 63c5452..26744a5 100644 --- a/runtime/src/main/java/com/fntsoftware/solr/runtime/SolrClientProducer.java +++ b/runtime/src/main/java/com/fntsoftware/solr/runtime/SolrClientProducer.java @@ -6,41 +6,18 @@ import jakarta.inject.Inject; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrServerException; -import org.apache.solr.client.solrj.impl.HttpJdkSolrClient; -import org.eclipse.microprofile.context.ManagedExecutor; import java.io.IOException; -import java.util.concurrent.TimeUnit; @ApplicationScoped public class SolrClientProducer { @Inject - SolrConnectionConfig config; - - @Inject - ManagedExecutor executor; + SolrClientRegistry registry; @Produces @ApplicationScoped @LookupUnlessProperty(name = "quarkus.solr.enabled", stringValue = "false") public SolrClient getClient() throws SolrServerException, IOException { - HttpJdkSolrClient.Builder builder = new HttpJdkSolrClient.Builder(config.url()) - .withExecutor(executor) - .useHttp1_1(config.useHttp1_1()) - .withRequestTimeout(config.requestTimeout(), TimeUnit.MILLISECONDS) - .withConnectionTimeout(config.connectionTimeout(), TimeUnit.MILLISECONDS) - .withIdleTimeout(config.idleTimeout(), TimeUnit.MILLISECONDS) - .withFollowRedirects(config.followRedirects()); - - if (config.auth().isPresent()) { - builder.withBasicAuthCredentials(config.auth().get().username(), config.auth().get().password()); - } - if (config.defaultCollection().isPresent()) { - builder.withDefaultCollection(config.defaultCollection().get()); - } - - SolrClient c = builder.build(); - c.ping(); - return c; + return registry.defaultClient(); } } diff --git a/runtime/src/main/java/com/fntsoftware/solr/runtime/SolrClientRegistry.java b/runtime/src/main/java/com/fntsoftware/solr/runtime/SolrClientRegistry.java new file mode 100644 index 0000000..11c859d --- /dev/null +++ b/runtime/src/main/java/com/fntsoftware/solr/runtime/SolrClientRegistry.java @@ -0,0 +1,99 @@ +package com.fntsoftware.solr.runtime; + +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.SolrServerException; +import org.apache.solr.client.solrj.impl.HttpJdkSolrClient; +import org.eclipse.microprofile.context.ManagedExecutor; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@ApplicationScoped +public class SolrClientRegistry { + private static final String DEFAULT_CLIENT = "default"; + + @Inject + SolrConnectionConfig config; + + @Inject + ManagedExecutor executor; + + private final Map clients = new HashMap<>(); + + public synchronized SolrClient defaultClient() throws SolrServerException, IOException { + return client(DEFAULT_CLIENT, config.url().orElseThrow( + () -> new IllegalStateException("quarkus.solr.url is required for the default Solr client"))); + } + + public synchronized SolrClient namedClient(String name) throws SolrServerException, IOException { + SolrConnectionConfig.ClientConfig clientConfig = config.clients().get(name); + if (clientConfig == null) { + throw new IllegalArgumentException("No Solr client configured with name '" + name + "'"); + } + return client(name, clientConfig.url()); + } + + public SolrClient namedClientForBean(String name) { + try { + return namedClient(name); + } catch (SolrServerException | IOException e) { + throw new IllegalStateException("Failed to create Solr client '" + name + "'", e); + } + } + + private SolrClient client(String name, String url) throws SolrServerException, IOException { + SolrClient existing = clients.get(name); + if (existing != null) { + return existing; + } + SolrClient created = createClient(url); + clients.put(name, created); + return created; + } + + private SolrClient createClient(String url) throws SolrServerException, IOException { + HttpJdkSolrClient.Builder builder = new HttpJdkSolrClient.Builder(url) + .withExecutor(executor) + .useHttp1_1(config.useHttp1_1()) + .withRequestTimeout(config.requestTimeout(), TimeUnit.MILLISECONDS) + .withConnectionTimeout(config.connectionTimeout(), TimeUnit.MILLISECONDS) + .withIdleTimeout(config.idleTimeout(), TimeUnit.MILLISECONDS) + .withFollowRedirects(config.followRedirects()); + + if (config.auth().isPresent()) { + builder.withBasicAuthCredentials(config.auth().get().username(), config.auth().get().password()); + } + if (config.defaultCollection().isPresent()) { + builder.withDefaultCollection(config.defaultCollection().get()); + } + + SolrClient client = builder.build(); + client.ping(); + return client; + } + + @PreDestroy + void close() throws IOException { + IOException failure = null; + for (SolrClient client : clients.values()) { + try { + client.close(); + } catch (IOException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + } + clients.clear(); + if (failure != null) { + throw failure; + } + } +} diff --git a/runtime/src/main/java/com/fntsoftware/solr/runtime/SolrConnectionConfig.java b/runtime/src/main/java/com/fntsoftware/solr/runtime/SolrConnectionConfig.java index 429327b..daa430d 100644 --- a/runtime/src/main/java/com/fntsoftware/solr/runtime/SolrConnectionConfig.java +++ b/runtime/src/main/java/com/fntsoftware/solr/runtime/SolrConnectionConfig.java @@ -5,6 +5,7 @@ import io.smallrye.config.ConfigMapping; import io.smallrye.config.WithDefault; +import java.util.Map; import java.util.Optional; @ConfigMapping(prefix = "quarkus.solr") @@ -24,7 +25,7 @@ public interface SolrConnectionConfig { * * @return */ - String url(); + Optional url(); /** * Whether the SolrJ client should follow redirects or not @@ -80,6 +81,22 @@ public interface SolrConnectionConfig { */ Optional defaultCollection(); + /** + * Named Solr clients for multi-core applications. + * + * @return the client configurations keyed by CDI name/core identifier + */ + Map clients(); + + interface ClientConfig { + /** + * The URL to the Solr core. + * + * @return + */ + String url(); + } + interface BasicAuthConfig { /** * Username to use for basic authentication. diff --git a/runtime/src/main/java/com/fntsoftware/solr/runtime/SolrDevserviceConfig.java b/runtime/src/main/java/com/fntsoftware/solr/runtime/SolrDevserviceConfig.java index c76684e..aa6d379 100644 --- a/runtime/src/main/java/com/fntsoftware/solr/runtime/SolrDevserviceConfig.java +++ b/runtime/src/main/java/com/fntsoftware/solr/runtime/SolrDevserviceConfig.java @@ -5,6 +5,9 @@ import io.smallrye.config.ConfigMapping; import io.smallrye.config.WithDefault; +import java.util.Map; +import java.util.Optional; + @ConfigMapping(prefix = "quarkus.solr.devservices") @ConfigRoot(phase = ConfigPhase.BUILD_TIME) public interface SolrDevserviceConfig { @@ -21,7 +24,7 @@ public interface SolrDevserviceConfig { * * @return */ - String core(); + Optional core(); /** * Which Solr version to use @@ -30,4 +33,20 @@ public interface SolrDevserviceConfig { */ @WithDefault("9.6.1") String version(); + + /** + * Named Solr cores for multi-core dev services. + * + * @return the core configurations keyed by core name + */ + Map cores(); + + interface CoreConfig { + /** + * Classpath path to the Solr core config directory. + * + * @return + */ + String configPath(); + } }