Skip to content
Merged
11 changes: 8 additions & 3 deletions .docker/solr.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,18 @@ COPY .docker/config/solr/config-set /solr-conf/conf/
USER root

# Apply custom modifications for Lagoon environment compatibility.
# The index is moved out of the core directory, which is recreated on every
# start, so the build fails if the dataDir replacement does not apply.
RUN sed -i -e "s#<dataDir>\${solr.data.dir:}#<dataDir>/var/solr/\${solr.core.name}#g" /solr-conf/conf/solrconfig.xml && \
grep -q "<dataDir>/var/solr/\${solr.core.name}</dataDir>" /solr-conf/conf/solrconfig.xml && \
sed -i -e "s#solr.lock.type:native#solr.lock.type:none#g" /solr-conf/conf/solrconfig.xml && \
sed -i -e "s#solr.autoSoftCommit.MaxTime=5000#solr.autoSoftCommit.MaxTime=-1#g" /solr-conf/conf/solrcore.properties

# hadolint ignore=DL3066 # named account provided by the base image
USER solr

# solr-precreate is provided by the base Solr container image.
# It pre-creates the core and then starts Solr in the foreground.
CMD ["solr-precreate", "drupal", "/solr-conf"]
# solr-recreate and solr-foreground are provided by the base Solr container
# image. solr-recreate replaces the core config on the persistent volume with
# the config-set from this image on every start, so config-set changes reach
# existing cores. exec makes Solr the direct child of tini to receive SIGTERM.
CMD ["sh", "-c", "solr-recreate drupal /solr-conf && exec solr-foreground"]
10 changes: 10 additions & 0 deletions .vortex/docs/content/development/environment/docker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,16 @@ there only when you enable the matching feature in the installer:
and keep it under version control. You reach Solr from the host on a
random port, which `docker compose port solr 8983` prints. The Solr admin
interface is at `http://localhost:<port>/solr/`.

The container recreates the `drupal` core from the image's config-set on
every start. A config-set change reaches an existing core on the next
deployment, even when `/var/solr` is a persistent volume. The index lives
in `/var/solr/drupal`, outside the core directory, so it survives each
restart. The build fails when the config-set's `dataDir` line differs from
`<dataDir>${solr.data.dir:}</dataDir>`, because the index would otherwise
sit inside the recreated core. After a config-set change that alters the
schema, reindex the content with `ahoy drush search-api:rebuild-tracker`
and `ahoy drush search-api:index`.
- `clamav` - an optional container that runs a ClamAV antivirus server,
which Drupal uses to scan uploaded files for viruses.
- `chrome` - runs Chromium, the open source browser that Chrome is built
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,18 @@ COPY .docker/config/solr/config-set /solr-conf/conf/
USER root

# Apply custom modifications for Lagoon environment compatibility.
# The index is moved out of the core directory, which is recreated on every
# start, so the build fails if the dataDir replacement does not apply.
RUN sed -i -e "s#<dataDir>\${solr.data.dir:}#<dataDir>/var/solr/\${solr.core.name}#g" /solr-conf/conf/solrconfig.xml && \
grep -q "<dataDir>/var/solr/\${solr.core.name}</dataDir>" /solr-conf/conf/solrconfig.xml && \
sed -i -e "s#solr.lock.type:native#solr.lock.type:none#g" /solr-conf/conf/solrconfig.xml && \
sed -i -e "s#solr.autoSoftCommit.MaxTime=5000#solr.autoSoftCommit.MaxTime=-1#g" /solr-conf/conf/solrcore.properties

# hadolint ignore=DL3066 # named account provided by the base image
USER solr

# solr-precreate is provided by the base Solr container image.
# It pre-creates the core and then starts Solr in the foreground.
CMD ["solr-precreate", "drupal", "/solr-conf"]
# solr-recreate and solr-foreground are provided by the base Solr container
# image. solr-recreate replaces the core config on the persistent volume with
# the config-set from this image on every start, so config-set changes reach
# existing cores. exec makes Solr the direct child of tini to receive SIGTERM.
CMD ["sh", "-c", "solr-recreate drupal /solr-conf && exec solr-foreground"]
144 changes: 144 additions & 0 deletions .vortex/tests/phpunit/Functional/DockerComposeSolrTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

declare(strict_types=1);

namespace DrevOps\Vortex\Tests\Functional;

use AlexSkrypnyk\File\File;
use PHPUnit\Framework\Attributes\Group;

/**
* Tests the Solr core lifecycle on a persistent volume.
*/
class DockerComposeSolrTest extends FunctionalTestCase {

const SOLR_URL = 'http://localhost:8983/solr';

const SOLR_DOC_ID = 'vortex-test-doc';

protected function setUp(): void {
parent::setUp();

static::$sutInstallerEnv = [];
static::$sutInstallerPrompts = [];

$this->forceVolumesUnmounted();

$this->dockerCleanup();
}

#[Group('p5')]
public function testSolrCoreConfigUpdate(): void {
$this->prepareSut();
$this->solrStart();

$this->logSubstep('Asserting the initial core config');
$this->assertSame('2000', $this->solrMltTimeAllowed(), 'Core should load the config-set shipped in the image');

$this->solrIndexDocument();

$this->logSubstep('Changing the config-set and rolling out a new image on the same volume');
File::replaceContentInFile('.docker/config/solr/config-set/solrcore.properties', 'solr.mlt.timeAllowed=2000', 'solr.mlt.timeAllowed=2345');
$this->assertFileContainsString('.docker/config/solr/config-set/solrcore.properties', 'solr.mlt.timeAllowed=2345');
$this->solrStart(recreate: TRUE);

$this->logSubstep('Asserting the existing core uses the changed config-set');
$this->assertSame('2345', $this->solrMltTimeAllowed(), 'Existing core should load the changed config-set');
$this->assertSame(1, $this->solrDocumentCount(), 'Index should survive the core config refresh');

$this->logSubstep('Asserting Solr is a direct child of the init process');
$process = $this->cmd('docker compose exec -T solr ps -o ppid= -C java', txt: 'Find the parent of the Java process');
$this->assertSame('1', trim($process->getOutput()), 'Java should be a direct child of tini to receive SIGTERM');
}

#[Group('p5')]
public function testSolrCoreIncompatibleConfigReplaced(): void {
$this->prepareSut();
$this->solrStart();
$this->solrIndexDocument();

$this->logSubstep('Writing a Solr 8 response writer into the core config on the volume');
$this->cmd('docker compose exec -T solr sed -i', txt: 'Inject Solr 8 config into the core on the volume', arg: ['s#</config>#<queryResponseWriter name="xslt" class="solr.XSLTResponseWriter"/></config>#', '/var/solr/data/drupal/conf/solrconfig.xml']);
$this->cmd('docker compose exec -T solr grep -q solr.XSLTResponseWriter /var/solr/data/drupal/conf/solrconfig.xml', txt: 'Core config on the volume should contain the Solr 8 response writer');

$this->logSubstep('Rolling out the same image on the same volume');
$this->solrStart(recreate: TRUE);

$this->logSubstep('Asserting the core loads with the config-set from the image');
$this->assertSame(1, $this->solrDocumentCount(), 'Index should survive the core config refresh');
}

#[Group('p5')]
public function testSolrBuildFailsWhenDataDirUnmatched(): void {
$this->prepareSut();

$this->logSubstep('Changing the dataDir in the config-set so the build no longer matches it');
File::replaceContentInFile('.docker/config/solr/config-set/solrconfig.xml', '<dataDir>${solr.data.dir:}</dataDir>', '<dataDir>${solr.data.dir:/var/solr/data}</dataDir>');
$this->assertFileContainsString('.docker/config/solr/config-set/solrconfig.xml', '<dataDir>${solr.data.dir:/var/solr/data}</dataDir>');

$this->cmdFail('docker compose build solr', txt: 'Build should fail when the index location cannot be moved out of the core directory', tio: 600);
}

/**
* Start the Solr service and wait for the 'drupal' core to load.
*
* @param bool $recreate
* Replace the running container, keeping the volume.
*/
protected function solrStart(bool $recreate = FALSE): void {
$this->logSubstep($recreate ? 'Recreating the Solr container' : 'Starting the Solr container');
// The image tag is shared across SUTs, so build to avoid a stale image.
$this->cmd('docker compose up -d --build solr' . ($recreate ? ' --force-recreate' : ''), txt: 'Start Solr container', tio: 600);

$failures = [];
$core = [];
for ($i = 0; $i < 60; $i++) {
$process = $this->processRun('docker compose exec -T solr curl -sf', [static::SOLR_URL . '/admin/cores?action=STATUS&core=drupal&wt=json']);
$decoded = json_decode($process->getOutput(), TRUE);

if ($process->isSuccessful() && is_array($decoded)) {
$failures = is_array($decoded['initFailures'] ?? NULL) ? $decoded['initFailures'] : [];
$statuses = is_array($decoded['status'] ?? NULL) ? $decoded['status'] : [];
$core = is_array($statuses['drupal'] ?? NULL) ? $statuses['drupal'] : [];

// A core that is still loading is listed without its 'index' details.
if (isset($failures['drupal']) || isset($core['index'])) {
break;
}
}

sleep(2);
}

$this->assertArrayNotHasKey('drupal', $failures, 'Core should not fail to load: ' . json_encode($failures));
$this->assertArrayHasKey('index', $core, 'Solr should finish loading the core');
$this->assertSame('drupal', $core['name'] ?? NULL, 'Core should be loaded');
}

protected function solrIndexDocument(): void {
$this->logSubstep('Indexing a document');
$this->cmd('docker compose exec -T solr curl -sf -H Content-Type:application/json --data-binary', txt: 'Index a document', arg: ['[{"id":"' . static::SOLR_DOC_ID . '"}]', static::SOLR_URL . '/drupal/update?commit=true']);
$this->assertSame(1, $this->solrDocumentCount(), 'Indexed document should be searchable');
}

protected function solrDocumentCount(): int {
$response = $this->solrGetJson('/drupal/select?q=id:' . static::SOLR_DOC_ID . '&rows=0&wt=json');

return (int) ($response['response']['numFound'] ?? -1);
}

protected function solrMltTimeAllowed(): ?string {
$response = $this->solrGetJson('/drupal/config/requestHandler?componentName=/mlt&wt=json');

return $response['config']['requestHandler']['/mlt']['defaults']['timeAllowed'] ?? NULL;
}

protected function solrGetJson(string $path): array {
$process = $this->cmd('docker compose exec -T solr curl -sf', arg: [static::SOLR_URL . $path]);
$decoded = json_decode($process->getOutput(), TRUE);
$this->assertIsArray($decoded, 'Solr should return JSON for ' . $path);

return $decoded;
}

}
Loading