diff --git a/.docker/solr.dockerfile b/.docker/solr.dockerfile index 6bc701b625..c16c36b5e0 100644 --- a/.docker/solr.dockerfile +++ b/.docker/solr.dockerfile @@ -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#\${solr.data.dir:}#/var/solr/\${solr.core.name}#g" /solr-conf/conf/solrconfig.xml && \ + grep -q "/var/solr/\${solr.core.name}" /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"] diff --git a/.vortex/docs/content/development/environment/docker.mdx b/.vortex/docs/content/development/environment/docker.mdx index d556af4af8..dc8f9d55e5 100644 --- a/.vortex/docs/content/development/environment/docker.mdx +++ b/.vortex/docs/content/development/environment/docker.mdx @@ -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:/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 + `${solr.data.dir:}`, 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 diff --git a/.vortex/installer/tests/Fixtures/handler_process/_baseline/.docker/solr.dockerfile b/.vortex/installer/tests/Fixtures/handler_process/_baseline/.docker/solr.dockerfile index 0ebae6b908..afccfc4b9e 100644 --- a/.vortex/installer/tests/Fixtures/handler_process/_baseline/.docker/solr.dockerfile +++ b/.vortex/installer/tests/Fixtures/handler_process/_baseline/.docker/solr.dockerfile @@ -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#\${solr.data.dir:}#/var/solr/\${solr.core.name}#g" /solr-conf/conf/solrconfig.xml && \ + grep -q "/var/solr/\${solr.core.name}" /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"] diff --git a/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php b/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php new file mode 100644 index 0000000000..4d16009076 --- /dev/null +++ b/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php @@ -0,0 +1,144 @@ +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###', '/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', '${solr.data.dir:}', '${solr.data.dir:/var/solr/data}'); + $this->assertFileContainsString('.docker/config/solr/config-set/solrconfig.xml', '${solr.data.dir:/var/solr/data}'); + + $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; + } + +}