From 1f388c7f618e35169fa8cdd47c95ea1bb00652dc Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 11 Nov 2026 19:12:35 +1100 Subject: [PATCH 1/8] [#3307] Added functional tests for refreshing the Solr core config on an existing volume. --- .../Functional/DockerComposeSolrTest.php | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 .vortex/tests/phpunit/Functional/DockerComposeSolrTest.php diff --git a/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php b/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php new file mode 100644 index 000000000..fd2cc14be --- /dev/null +++ b/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php @@ -0,0 +1,136 @@ +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->cmd('docker compose build solr', txt: 'Build Solr image with the changed config-set', tio: 600); + $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', arg: ['s###', '/var/solr/data/drupal/conf/solrconfig.xml'], txt: 'Inject Solr 8 config into the core on the volume'); + + $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'); + $this->cmd('docker compose up -d solr' . ($recreate ? ' --force-recreate' : ''), txt: 'Start Solr container', tio: 600); + + $status = []; + 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)) { + $status = $decoded; + break; + } + + sleep(2); + } + + $this->assertNotEmpty($status, 'Solr should respond to the core admin API'); + $this->assertArrayNotHasKey('drupal', (array) ($status['initFailures'] ?? []), 'Core should not fail to load: ' . json_encode($status['initFailures'] ?? [])); + $this->assertSame('drupal', $status['status']['drupal']['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', arg: ['[{"id":"' . static::SOLR_DOC_ID . '"}]', static::SOLR_URL . '/drupal/update?commit=true'], txt: 'Index a document'); + $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; + } + +} From cd87f0ab31aa49fb7758c4fd9299e4a22505f9fa Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 11 Nov 2026 19:15:29 +1100 Subject: [PATCH 2/8] [#3307] Rebuilt the Solr image on every start in the Solr functional tests. --- .vortex/tests/phpunit/Functional/DockerComposeSolrTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php b/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php index fd2cc14be..902c0aecd 100644 --- a/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php +++ b/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php @@ -40,7 +40,6 @@ public function testSolrCoreConfigUpdate(): void { $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->cmd('docker compose build solr', txt: 'Build Solr image with the changed config-set', tio: 600); $this->solrStart(recreate: TRUE); $this->logSubstep('Asserting the existing core uses the changed config-set'); @@ -87,7 +86,8 @@ public function testSolrBuildFailsWhenDataDirUnmatched(): void { */ protected function solrStart(bool $recreate = FALSE): void { $this->logSubstep($recreate ? 'Recreating the Solr container' : 'Starting the Solr container'); - $this->cmd('docker compose up -d solr' . ($recreate ? ' --force-recreate' : ''), txt: 'Start Solr container', tio: 600); + // 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); $status = []; for ($i = 0; $i < 60; $i++) { From 723fc3fdb173cfb1ec36a259720fa5c747a3f087 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 11 Nov 2026 19:16:37 +1100 Subject: [PATCH 3/8] [#3307] Waited for the Solr core to open its index in the Solr functional tests. --- .vortex/tests/phpunit/Functional/DockerComposeSolrTest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php b/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php index 902c0aecd..094450502 100644 --- a/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php +++ b/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php @@ -94,7 +94,8 @@ protected function solrStart(bool $recreate = FALSE): void { $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)) { + // A core that is still loading is listed without its 'index' details. + if ($process->isSuccessful() && is_array($decoded) && (isset($decoded['initFailures']['drupal']) || isset($decoded['status']['drupal']['index']))) { $status = $decoded; break; } @@ -102,7 +103,7 @@ protected function solrStart(bool $recreate = FALSE): void { sleep(2); } - $this->assertNotEmpty($status, 'Solr should respond to the core admin API'); + $this->assertNotEmpty($status, 'Solr should finish loading the core'); $this->assertArrayNotHasKey('drupal', (array) ($status['initFailures'] ?? []), 'Core should not fail to load: ' . json_encode($status['initFailures'] ?? [])); $this->assertSame('drupal', $status['status']['drupal']['name'] ?? NULL, 'Core should be loaded'); } From 261212465e40dc8d14f06da4d33263d711f1f960 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 11 Nov 2026 19:17:45 +1100 Subject: [PATCH 4/8] [#3307] Fixed the Solr 8 config injection in the Solr functional tests. --- .vortex/tests/phpunit/Functional/DockerComposeSolrTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php b/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php index 094450502..2e9de6679 100644 --- a/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php +++ b/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php @@ -58,7 +58,8 @@ public function testSolrCoreIncompatibleConfigReplaced(): void { $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', arg: ['s###', '/var/solr/data/drupal/conf/solrconfig.xml'], txt: 'Inject Solr 8 config into the core on the volume'); + $this->cmd('docker compose exec -T solr sed -i', arg: ['s###', '/var/solr/data/drupal/conf/solrconfig.xml'], txt: 'Inject Solr 8 config into the core on the volume'); + $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); From a8e7af7f8c6317f52dc31e8ad2d0937fb73dcc99 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 11 Nov 2026 19:18:21 +1100 Subject: [PATCH 5/8] [#3307] Recreated the Solr core from the image config-set on every start. --- .docker/solr.dockerfile | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.docker/solr.dockerfile b/.docker/solr.dockerfile index 6bc701b62..c16c36b5e 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"] From f94c36a9cf4b33ba7ef861340e5b7ed4c652b537 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 11 Nov 2026 19:18:51 +1100 Subject: [PATCH 6/8] [#3307] Documented that the Solr core config is refreshed on every start. --- .../docs/content/development/environment/docker.mdx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.vortex/docs/content/development/environment/docker.mdx b/.vortex/docs/content/development/environment/docker.mdx index d556af4af..dc8f9d55e 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 From 2fa19f309b95e49b5f844712ba8aabeb211252a3 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 11 Nov 2026 19:19:33 +1100 Subject: [PATCH 7/8] Updated baseline. --- .../handler_process/_baseline/.docker/solr.dockerfile | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 0ebae6b90..afccfc4b9 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"] From e769bc5402902e2d9b7e6552ba33dd4c5a8cd505 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 11 Nov 2026 19:24:18 +1100 Subject: [PATCH 8/8] [#3307] Narrowed the Solr core status types in the Solr functional tests. --- .../Functional/DockerComposeSolrTest.php | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php b/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php index 2e9de6679..4d1600907 100644 --- a/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php +++ b/.vortex/tests/phpunit/Functional/DockerComposeSolrTest.php @@ -58,7 +58,7 @@ public function testSolrCoreIncompatibleConfigReplaced(): void { $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', arg: ['s###', '/var/solr/data/drupal/conf/solrconfig.xml'], txt: 'Inject Solr 8 config into the core 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'); @@ -90,28 +90,34 @@ protected function solrStart(bool $recreate = FALSE): void { // 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); - $status = []; + $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); - // A core that is still loading is listed without its 'index' details. - if ($process->isSuccessful() && is_array($decoded) && (isset($decoded['initFailures']['drupal']) || isset($decoded['status']['drupal']['index']))) { - $status = $decoded; - break; + 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->assertNotEmpty($status, 'Solr should finish loading the core'); - $this->assertArrayNotHasKey('drupal', (array) ($status['initFailures'] ?? []), 'Core should not fail to load: ' . json_encode($status['initFailures'] ?? [])); - $this->assertSame('drupal', $status['status']['drupal']['name'] ?? NULL, 'Core should be loaded'); + $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', arg: ['[{"id":"' . static::SOLR_DOC_ID . '"}]', static::SOLR_URL . '/drupal/update?commit=true'], txt: 'Index 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'); }