Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<your core name>
# 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
Expand All @@ -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
Expand Down
13 changes: 12 additions & 1 deletion deployment/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,18 @@
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.20.0</version>
<version>2.0.4</version>
</dependency>
<!-- Keep docker-java aligned with Testcontainers 2.x; Quarkus 3.12 otherwise resolves an older Docker client. -->
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-api</artifactId>
<version>3.7.1</version>
</dependency>
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-transport-zerodep</artifactId>
<version>3.7.1</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,75 @@
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 {
SolrDevserviceConfig config;
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<SyntheticBeanBuildItem> 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) {
Expand All @@ -47,15 +86,50 @@ public DevServicesResultBuildItem createContainer(CuratedApplicationShutdownBuil
devService = null;
};
closeBuildItem.addCloseTask(closeTask, false);

Map<String, SolrDevserviceConfig.CoreConfig> 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<String, String> 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<String, SolrDevserviceConfig.CoreConfig> cores) {
ImageFromDockerfile image = new ImageFromDockerfile("quarkus/devservices/solr");
for (Map.Entry<String, SolrDevserviceConfig.CoreConfig> 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<String, String> 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();
Expand All @@ -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<SolrContainer> {
static final int PORT = 8983;
private final Set<String> cores;

public SolrContainer(ImageFromDockerfile image) {
public SolrContainer(ImageFromDockerfile image, Set<String> cores) {
super(image);
this.cores = Set.copyOf(cores);
}

public int getPort() {
Expand All @@ -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<String> 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<String> configuredNamedClients() {
Config config = ConfigProvider.getConfig();
Set<String> 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<String> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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 = """
<config>
<luceneMatchVersion>9.6.1</luceneMatchVersion>
<directoryFactory name="DirectoryFactory" class="${solr.directoryFactory:solr.NRTCachingDirectoryFactory}"/>
<schemaFactory class="ClassicIndexSchemaFactory"/>
<updateHandler class="solr.DirectUpdateHandler2"/>
<requestHandler name="/select" class="solr.SearchHandler"/>
<requestHandler name="/admin/ping" class="solr.PingRequestHandler">
<lst name="invariants">
<str name="q">*:*</str>
</lst>
</requestHandler>
</config>
""";

private static final String SCHEMA = """
<schema name="test" version="1.6">
<fieldType name="string" class="solr.StrField"/>
<field name="id" type="string" indexed="true" stored="true" required="true"/>
<uniqueKey>id</uniqueKey>
</schema>
""";

@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());
}
}
Loading