From 5b67783ed699325dd2a1eb6d8951862d56da85c6 Mon Sep 17 00:00:00 2001 From: Scott Wicken <1562170+swicken@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:22:31 -0400 Subject: [PATCH 1/5] fix(opensearch): enforce ES/OS endpoint separation at the OS index-creation chokepoint (#36419) The endpoint-separation guard only ran in InitServlet -> checkAndInitializeIndex, which executes after the empty-DB starter-load path (Task00004LoadStarter -> bootstrapAndPoint -> bootstrapAndPointOS) has already created the .os shadow indices. With ES==OS configured, the guard correctly halted the migration but too late: the .os indices and their `indicies` rows already existed (fails TC-037). Move the separation check to the single OS index-creation chokepoint (bootstrapAndPointOS), alongside the existing connection gate: - IndexStartupValidator: extract the overlap logic into a shared assertEndpointsSeparate(config) and add a config-only, network-free endpointsAreSeparate() gate reused by both startup validation and the chokepoint. - bootstrapAndPointOS: refuse OS bootstrap and haltMigration() on ES==OS overlap, before the connection gate, so no .os artifacts are created on any startup path. Verified e2e: with ES==OS, 0 .os rows / 0 .os cluster indices, migration halts to Phase 0, dotCMS serves on ES. Adds IndexStartupValidatorTest. --- .../business/ContentletIndexAPIImpl.java | 15 ++++ .../opensearch/IndexStartupValidator.java | 36 ++++++++- .../opensearch/IndexStartupValidatorTest.java | 81 +++++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 dotCMS/src/test/java/com/dotcms/content/index/opensearch/IndexStartupValidatorTest.java diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java index b456c46c0596..ffe9a030b501 100644 --- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java @@ -1008,6 +1008,21 @@ private void bootstrapAndPointES(final String workingName, final String liveName private void bootstrapAndPointOS(final String workingName, final String liveName) throws DotDataException { + // Separation gate (issue #36419): OS must be a SEPARATE cluster from ES. Config-only, + // no network I/O, so it runs before the connection gate. Placing it at this single + // chokepoint closes the window where the empty-DB starter-load path created .os indices + // before InitServlet's later validateIndexingConfig() caught the ES==OS overlap. On + // overlap we halt the migration (ES-only) and skip OS bootstrap entirely. + if (!IndexStartupValidator.endpointsAreSeparate()) { + Logger.warn(this.getClass(), + "Skipping OpenSearch index bootstrap (working=" + workingName + + ", live=" + liveName + "): OS endpoints overlap ES (same cluster)." + + " Migration halted (now ES-only). Set OS_ENDPOINTS to a separate" + + " OpenSearch instance and restart."); + haltMigration(); + return; + } + // Connection gate (issue #36244): verify OS reachability BEFORE creating OS indices. // This is the single chokepoint for all OS index creation (fresh-install bootstrap and // migration catchup), so both startup paths — populated-DB (InitServlet) and empty-DB diff --git a/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java b/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java index b71c758e671c..9255d5d312f6 100644 --- a/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java +++ b/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java @@ -217,6 +217,18 @@ private void validateOSVersion() { * @throws DotRuntimeException if at least one endpoint is common to both clients */ private void validateEndpointSeparation(final OSClientConfig config) { + assertEndpointsSeparate(config); + Logger.info(this, "Endpoint separation check passed."); + } + + /** + * Asserts that the ES and OS clients do not share any {@code host:port}. Shared by the + * startup validator ({@link #validate()}) and the config-only OS index-creation gate + * ({@link #endpointsAreSeparate()}) so both enforce separation with identical logic. + * + * @throws DotRuntimeException if at least one endpoint is common to both clients + */ + static void assertEndpointsSeparate(final OSClientConfig config) { final Set esEndpoints = resolveESEndpoints(); final Set osEndpoints = config.endpoints().stream() .map(IndexStartupValidator::normalizeEndpoint) @@ -232,8 +244,28 @@ private void validateEndpointSeparation(final OSClientConfig config) { + " — OS endpoints: " + osEndpoints + ". Set OS_ENDPOINTS to a separate OpenSearch instance."); } - Logger.info(this, "Endpoint separation check passed." - + " ES: " + esEndpoints + " — OS: " + osEndpoints); + } + + /** + * Config-only endpoint-separation gate for the OS index-creation chokepoint + * ({@code ContentletIndexAPIImpl.bootstrapAndPointOS}, issue #36419). Unlike + * {@link #validate()} this performs no network I/O — it only compares the resolved ES and OS + * endpoints — so it is cheap enough to run before every OS bootstrap on any startup path + * (empty-DB starter-load or populated-DB InitServlet), closing the window where the + * starter-load path created {@code .os} indices before the late startup validation ran. + * + * @return {@code true} when ES and OS point to distinct clusters (safe to create OS indices); + * {@code false} when they overlap (caller must skip the OS bootstrap and halt the migration) + */ + public static boolean endpointsAreSeparate() { + try { + assertEndpointsSeparate(ConfigurableOpenSearchProvider.configFromProperties()); + return true; + } catch (Exception e) { + Logger.error(IndexStartupValidator.class, + "OpenSearch endpoint-separation check failed: " + e.getMessage()); + return false; + } } /** diff --git a/dotCMS/src/test/java/com/dotcms/content/index/opensearch/IndexStartupValidatorTest.java b/dotCMS/src/test/java/com/dotcms/content/index/opensearch/IndexStartupValidatorTest.java new file mode 100644 index 000000000000..964c1d84c6b1 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/content/index/opensearch/IndexStartupValidatorTest.java @@ -0,0 +1,81 @@ +package com.dotcms.content.index.opensearch; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.dotmarketing.exception.DotRuntimeException; +import com.dotmarketing.util.Config; +import org.junit.After; +import org.junit.Test; + +/** + * Unit tests for {@link IndexStartupValidator} endpoint-separation logic. + * + *

Covers the config-only separation gate ({@link IndexStartupValidator#endpointsAreSeparate()}) + * used at the OS index-creation chokepoint (issue #36419): OS must point to a cluster separate + * from ES, otherwise the {@code .os} shadow indices must not be created.

+ */ +public class IndexStartupValidatorTest { + + @After + public void clearEndpoints() { + Config.setProperty("ES_ENDPOINTS", null); + Config.setProperty("OS_ENDPOINTS", null); + } + + /** + * Given Scenario: ES and OS are configured with the same address. + * Expected Result: endpointsAreSeparate() returns false — OS bootstrap must be skipped. + */ + @Test + public void test_endpointsAreSeparate_sameAddress_returnsFalse() { + Config.setProperty("ES_ENDPOINTS", new String[]{"https://localhost:9201"}); + Config.setProperty("OS_ENDPOINTS", new String[]{"https://localhost:9201"}); + + assertFalse(IndexStartupValidator.endpointsAreSeparate()); + } + + /** + * Given Scenario: ES and OS point at different ports on the same host. + * Expected Result: endpointsAreSeparate() returns true — safe to create OS indices. + */ + @Test + public void test_endpointsAreSeparate_differentAddress_returnsTrue() { + Config.setProperty("ES_ENDPOINTS", new String[]{"https://localhost:9200"}); + Config.setProperty("OS_ENDPOINTS", new String[]{"https://localhost:9201"}); + + assertTrue(IndexStartupValidator.endpointsAreSeparate()); + } + + /** + * Given Scenario: same address, but written with different scheme/formatting. + * Expected Result: overlap is still detected (normalized to host:port) — returns false. + */ + @Test + public void test_endpointsAreSeparate_sameHostPortDifferentScheme_returnsFalse() { + Config.setProperty("ES_ENDPOINTS", new String[]{"http://localhost:9201"}); + Config.setProperty("OS_ENDPOINTS", new String[]{"https://localhost:9201"}); + + assertFalse(IndexStartupValidator.endpointsAreSeparate()); + } + + /** + * Given Scenario: ES and OS share the same host:port. + * Expected Result: assertEndpointsSeparate throws DotRuntimeException naming the overlap. + */ + @Test + public void test_assertEndpointsSeparate_overlap_throws() { + Config.setProperty("ES_ENDPOINTS", new String[]{"https://localhost:9201"}); + Config.setProperty("OS_ENDPOINTS", new String[]{"https://localhost:9201"}); + + try { + IndexStartupValidator.assertEndpointsSeparate( + ConfigurableOpenSearchProvider.configFromProperties()); + fail("Expected DotRuntimeException for overlapping ES/OS endpoints"); + } catch (final DotRuntimeException e) { + assertTrue("message should name the overlap", + e.getMessage().contains("point to the same endpoint(s)")); + } + } +} From 4224e83d95d0a29f0810eccac753c6f2a5ee9909 Mon Sep 17 00:00:00 2001 From: Scott Wicken <1562170+swicken@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:34:10 -0400 Subject: [PATCH 2/5] fix(opensearch): distinguish OS config-resolution failure from endpoint overlap (#36419) Review follow-up: endpointsAreSeparate() caught all exceptions and returned false, so a configFromProperties() parse failure looked identical to an ES==OS overlap. The caller then logged a hardcoded "OS endpoints overlap ES (same cluster)", misdirecting the operator to a same-endpoint problem that may not exist. - endpointsAreSeparate(): resolve config and run the overlap check in separate try blocks, each with a distinct ERROR (config-resolution failure vs overlap). - bootstrapAndPointOS: soften the WARN to defer to the preceding error for the cause instead of asserting an overlap. --- .../business/ContentletIndexAPIImpl.java | 6 +++--- .../index/opensearch/IndexStartupValidator.java | 17 ++++++++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java index ffe9a030b501..d5c363252710 100644 --- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java @@ -1016,9 +1016,9 @@ private void bootstrapAndPointOS(final String workingName, final String liveName if (!IndexStartupValidator.endpointsAreSeparate()) { Logger.warn(this.getClass(), "Skipping OpenSearch index bootstrap (working=" + workingName - + ", live=" + liveName + "): OS endpoints overlap ES (same cluster)." - + " Migration halted (now ES-only). Set OS_ENDPOINTS to a separate" - + " OpenSearch instance and restart."); + + ", live=" + liveName + "): OS migration configuration rejected" + + " (see the preceding error for the cause — e.g. ES/OS endpoint overlap or" + + " an unresolved OS config). Migration halted (now ES-only)."); haltMigration(); return; } diff --git a/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java b/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java index 9255d5d312f6..7bfd942355d7 100644 --- a/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java +++ b/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java @@ -258,12 +258,23 @@ static void assertEndpointsSeparate(final OSClientConfig config) { * {@code false} when they overlap (caller must skip the OS bootstrap and halt the migration) */ public static boolean endpointsAreSeparate() { + final OSClientConfig config; try { - assertEndpointsSeparate(ConfigurableOpenSearchProvider.configFromProperties()); - return true; + config = ConfigurableOpenSearchProvider.configFromProperties(); } catch (Exception e) { + // Config could not be resolved — this is NOT an ES/OS overlap. Log it distinctly so the + // operator is not sent chasing a same-endpoint misconfiguration that does not exist. Logger.error(IndexStartupValidator.class, - "OpenSearch endpoint-separation check failed: " + e.getMessage()); + "Cannot resolve OpenSearch configuration for the endpoint-separation check: " + + e.getMessage()); + return false; + } + try { + assertEndpointsSeparate(config); + return true; + } catch (DotRuntimeException e) { + // The overlap message (with the actionable "Set OS_ENDPOINTS to a separate instance"). + Logger.error(IndexStartupValidator.class, e.getMessage()); return false; } } From 1779fcda316d71662702248e4b98cb50653d03bb Mon Sep 17 00:00:00 2001 From: Scott Wicken <1562170+swicken@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:52:38 -0400 Subject: [PATCH 3/5] fix(opensearch): make separation gate phase-aware; restore pass-log sets; add tests (#36419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on PR #36421: - Phase 3 (isMigrationComplete): the gate threw away the codebase's "never silently fall back to stale ES" invariant by calling haltMigration() unconditionally. Now it throws in Phase 3 (matching checkAndInitializeIndex and the connection gate); the non-Phase-3 path still halts to ES-only. Bites initOSCatchup Case 1 (Phase-3 DR). - Restore the resolved ES/OS sets in the "Endpoint separation check passed" log line (dropped in the extraction); it now lives in the shared assertEndpointsSeparate. - Add tests: multi-endpoint partial overlap, and config-resolution failure (fail-closed). - Soften the "single chokepoint" comment — it covers working/live bootstrap, not reindex-slot creation (initAndPointReindex). --- .../business/ContentletIndexAPIImpl.java | 15 ++++++++++- .../opensearch/IndexStartupValidator.java | 10 +++---- .../opensearch/IndexStartupValidatorTest.java | 27 +++++++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java index d5c363252710..fcef4c85f317 100644 --- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java @@ -1014,6 +1014,17 @@ private void bootstrapAndPointOS(final String workingName, final String liveName // before InitServlet's later validateIndexingConfig() caught the ES==OS overlap. On // overlap we halt the migration (ES-only) and skip OS bootstrap entirely. if (!IndexStartupValidator.endpointsAreSeparate()) { + if (isMigrationComplete()) { + // Phase 3: ES is decommissioned. Silently halting to ES-only would serve a stale + // or absent ES index — the same reason checkAndInitializeIndex and the connection + // gate fail loudly in Phase 3. Throw instead of haltMigration(); the outer handler + // logs it FATAL. This bites initOSCatchup Case 1 (Phase-3 disaster recovery). + throw new DotRuntimeException( + "OpenSearch endpoint-separation check failed in PHASE_3_OPENSEARCH_ONLY" + + " (ES and OS resolve to the same cluster, or the OS config is unresolved)." + + " Cannot auto-rollback to ES (ES may be decommissioned or stale)." + + " Fix OS_ENDPOINTS and restart dotCMS."); + } Logger.warn(this.getClass(), "Skipping OpenSearch index bootstrap (working=" + workingName + ", live=" + liveName + "): OS migration configuration rejected" @@ -1024,10 +1035,12 @@ private void bootstrapAndPointOS(final String workingName, final String liveName } // Connection gate (issue #36244): verify OS reachability BEFORE creating OS indices. - // This is the single chokepoint for all OS index creation (fresh-install bootstrap and + // This is the single chokepoint for OS working/live index bootstrap (fresh-install and // migration catchup), so both startup paths — populated-DB (InitServlet) and empty-DB // (Task00004LoadStarter) — pass through the same phase-aware gate instead of failing // late and opaquely with a transport exception deep inside createContentIndex. + // (Reindex-slot creation via initAndPointReindex does NOT pass through here — see the + // runtime phase-flip caveat in PR #36421.) // // operationsOS.indexAPI() is the OS-specific IndexAPI, so the gate always probes OS // regardless of the current read provider (in Phase 1 the read provider is ES). The diff --git a/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java b/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java index 7bfd942355d7..74be3d401890 100644 --- a/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java +++ b/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java @@ -87,7 +87,7 @@ public void validate() { final OSClientConfig config = resolveConfig(); logConfigSummary(config); validateOSVersion(); - validateEndpointSeparation(config); + assertEndpointsSeparate(config); } // ------------------------------------------------------------------------- @@ -216,15 +216,11 @@ private void validateOSVersion() { * * @throws DotRuntimeException if at least one endpoint is common to both clients */ - private void validateEndpointSeparation(final OSClientConfig config) { - assertEndpointsSeparate(config); - Logger.info(this, "Endpoint separation check passed."); - } - /** * Asserts that the ES and OS clients do not share any {@code host:port}. Shared by the * startup validator ({@link #validate()}) and the config-only OS index-creation gate * ({@link #endpointsAreSeparate()}) so both enforce separation with identical logic. + * On success it logs the compared sets so the "passed" line is proof of what was compared. * * @throws DotRuntimeException if at least one endpoint is common to both clients */ @@ -244,6 +240,8 @@ static void assertEndpointsSeparate(final OSClientConfig config) { + " — OS endpoints: " + osEndpoints + ". Set OS_ENDPOINTS to a separate OpenSearch instance."); } + Logger.info(IndexStartupValidator.class, "Endpoint separation check passed." + + " ES: " + esEndpoints + " — OS: " + osEndpoints); } /** diff --git a/dotCMS/src/test/java/com/dotcms/content/index/opensearch/IndexStartupValidatorTest.java b/dotCMS/src/test/java/com/dotcms/content/index/opensearch/IndexStartupValidatorTest.java index 964c1d84c6b1..bb4c30f58611 100644 --- a/dotCMS/src/test/java/com/dotcms/content/index/opensearch/IndexStartupValidatorTest.java +++ b/dotCMS/src/test/java/com/dotcms/content/index/opensearch/IndexStartupValidatorTest.java @@ -60,6 +60,33 @@ public void test_endpointsAreSeparate_sameHostPortDifferentScheme_returnsFalse() assertFalse(IndexStartupValidator.endpointsAreSeparate()); } + /** + * Given Scenario: multi-endpoint ES [a,b] and OS [b,c] share one endpoint. + * Expected Result: partial overlap is detected — returns false. + */ + @Test + public void test_endpointsAreSeparate_multiEndpointPartialOverlap_returnsFalse() { + Config.setProperty("ES_ENDPOINTS", + new String[]{"https://es-a:9200", "https://shared:9200"}); + Config.setProperty("OS_ENDPOINTS", + new String[]{"https://shared:9200", "https://os-c:9200"}); + + assertFalse(IndexStartupValidator.endpointsAreSeparate()); + } + + /** + * Given Scenario: OS config cannot be resolved (blank endpoint trips OSClientConfig validation). + * Expected Result: endpointsAreSeparate() fails closed — returns false — rather than throwing + * out of the OS bootstrap gate. + */ + @Test + public void test_endpointsAreSeparate_unresolvableOsConfig_returnsFalse() { + Config.setProperty("ES_ENDPOINTS", new String[]{"https://localhost:9200"}); + Config.setProperty("OS_ENDPOINTS", new String[]{""}); + + assertFalse(IndexStartupValidator.endpointsAreSeparate()); + } + /** * Given Scenario: ES and OS share the same host:port. * Expected Result: assertEndpointsSeparate throws DotRuntimeException naming the overlap. From c7efcd92ae979fedab4b9c2f3294e12bc9732387 Mon Sep 17 00:00:00 2001 From: Scott Wicken <1562170+swicken@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:07:01 -0400 Subject: [PATCH 4/5] docs(opensearch): remove stale duplicate javadoc on assertEndpointsSeparate (#36419) --- .../index/opensearch/IndexStartupValidator.java | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java b/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java index 74be3d401890..70f1d1a543c4 100644 --- a/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java +++ b/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java @@ -203,25 +203,15 @@ private void validateOSVersion() { // Endpoint separation check // ------------------------------------------------------------------------- - /** - * Resolves the effective ES and OS endpoint sets and asserts they do not share - * any {@code host:port} pair. - * - *

The OS side comes from the already-resolved {@link OSClientConfig#endpoints()} (the very - * endpoints the client is built with), and the ES side is read from config; both are passed - * through the same {@link #normalizeEndpoint} path so the comparison is consistent.

- * - *

Best-effort: two configs that refer to the same host via different forms - * (e.g. {@code "127.0.0.1"} vs {@code "localhost"}) will not be detected as overlapping.

- * - * @throws DotRuntimeException if at least one endpoint is common to both clients - */ /** * Asserts that the ES and OS clients do not share any {@code host:port}. Shared by the * startup validator ({@link #validate()}) and the config-only OS index-creation gate * ({@link #endpointsAreSeparate()}) so both enforce separation with identical logic. * On success it logs the compared sets so the "passed" line is proof of what was compared. * + *

Best-effort: two configs that refer to the same host via different forms + * (e.g. {@code "127.0.0.1"} vs {@code "localhost"}) will not be detected as overlapping.

+ * * @throws DotRuntimeException if at least one endpoint is common to both clients */ static void assertEndpointsSeparate(final OSClientConfig config) { From 456799422fe97cb16abedd54ee531ea4903d0f56 Mon Sep 17 00:00:00 2001 From: Scott Wicken <1562170+swicken@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:11:25 -0400 Subject: [PATCH 5/5] fix(opensearch): skip endpoint-separation check in Phase 3 (#36419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In PHASE_3_OPENSEARCH_ONLY ES is decommissioned and ES_ENDPOINTS is no longer required, so the resolved ES side falls back to the localhost:9200 default — comparing it against OS would false-positive on a legitimate configuration and block startup. Per review on PR #36421, don't run the check at all in Phase 3: - endpointsAreSeparate() and validate() now skip (and log) the separation check when the migration is complete - drop the now-unreachable Phase-3 throw branch in bootstrapAndPointOS - add a Phase-3 skip test; reset the phase flag in test teardown --- .../business/ContentletIndexAPIImpl.java | 14 +++----------- .../opensearch/IndexStartupValidator.java | 19 +++++++++++++++++++ .../opensearch/IndexStartupValidatorTest.java | 16 ++++++++++++++++ 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java index fcef4c85f317..b5457d58b96a 100644 --- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java @@ -1013,18 +1013,10 @@ private void bootstrapAndPointOS(final String workingName, final String liveName // chokepoint closes the window where the empty-DB starter-load path created .os indices // before InitServlet's later validateIndexingConfig() caught the ES==OS overlap. On // overlap we halt the migration (ES-only) and skip OS bootstrap entirely. + // Phase-aware: in Phase 3 (ES decommissioned, ES_ENDPOINTS not required) the check is + // skipped inside endpointsAreSeparate(), so this branch never fires there and the + // haltMigration() fallback below only ever runs in dual-write phases where ES is live. if (!IndexStartupValidator.endpointsAreSeparate()) { - if (isMigrationComplete()) { - // Phase 3: ES is decommissioned. Silently halting to ES-only would serve a stale - // or absent ES index — the same reason checkAndInitializeIndex and the connection - // gate fail loudly in Phase 3. Throw instead of haltMigration(); the outer handler - // logs it FATAL. This bites initOSCatchup Case 1 (Phase-3 disaster recovery). - throw new DotRuntimeException( - "OpenSearch endpoint-separation check failed in PHASE_3_OPENSEARCH_ONLY" - + " (ES and OS resolve to the same cluster, or the OS config is unresolved)." - + " Cannot auto-rollback to ES (ES may be decommissioned or stale)." - + " Fix OS_ENDPOINTS and restart dotCMS."); - } Logger.warn(this.getClass(), "Skipping OpenSearch index bootstrap (working=" + workingName + ", live=" + liveName + "): OS migration configuration rejected" diff --git a/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java b/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java index 70f1d1a543c4..f4a34ace8bfb 100644 --- a/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java +++ b/dotCMS/src/main/java/com/dotcms/content/index/opensearch/IndexStartupValidator.java @@ -87,6 +87,14 @@ public void validate() { final OSClientConfig config = resolveConfig(); logConfigSummary(config); validateOSVersion(); + if (IndexConfigHelper.isMigrationComplete()) { + // Phase 3: ES is decommissioned and ES_ENDPOINTS is no longer required, so the + // resolved ES side would just be the localhost:9200 default — comparing it against + // OS would false-positive on a legitimate config. Skip the separation check. + Logger.info(this, "Endpoint separation check skipped: PHASE_3_OPENSEARCH_ONLY" + + " — ES is decommissioned and ES_ENDPOINTS is not required."); + return; + } assertEndpointsSeparate(config); } @@ -242,10 +250,21 @@ static void assertEndpointsSeparate(final OSClientConfig config) { * (empty-DB starter-load or populated-DB InitServlet), closing the window where the * starter-load path created {@code .os} indices before the late startup validation ran. * + *

Phase-aware: in {@code PHASE_3_OPENSEARCH_ONLY} the check is skipped + * (returns {@code true}) — ES is decommissioned and {@code ES_ENDPOINTS} is no longer + * required, so the resolved ES side would just be the {@code localhost:9200} default and + * comparing it against OS would false-positive on a legitimate configuration.

+ * * @return {@code true} when ES and OS point to distinct clusters (safe to create OS indices); * {@code false} when they overlap (caller must skip the OS bootstrap and halt the migration) */ public static boolean endpointsAreSeparate() { + if (IndexConfigHelper.isMigrationComplete()) { + Logger.info(IndexStartupValidator.class, + "Endpoint separation check skipped: PHASE_3_OPENSEARCH_ONLY" + + " — ES is decommissioned and ES_ENDPOINTS is not required."); + return true; + } final OSClientConfig config; try { config = ConfigurableOpenSearchProvider.configFromProperties(); diff --git a/dotCMS/src/test/java/com/dotcms/content/index/opensearch/IndexStartupValidatorTest.java b/dotCMS/src/test/java/com/dotcms/content/index/opensearch/IndexStartupValidatorTest.java index bb4c30f58611..d58d421d50f9 100644 --- a/dotCMS/src/test/java/com/dotcms/content/index/opensearch/IndexStartupValidatorTest.java +++ b/dotCMS/src/test/java/com/dotcms/content/index/opensearch/IndexStartupValidatorTest.java @@ -4,6 +4,7 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import com.dotcms.content.index.IndexConfigHelper; import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.util.Config; import org.junit.After; @@ -22,6 +23,7 @@ public class IndexStartupValidatorTest { public void clearEndpoints() { Config.setProperty("ES_ENDPOINTS", null); Config.setProperty("OS_ENDPOINTS", null); + Config.setProperty(IndexConfigHelper.MigrationPhase.FLAG_KEY, 0); } /** @@ -87,6 +89,20 @@ public void test_endpointsAreSeparate_unresolvableOsConfig_returnsFalse() { assertFalse(IndexStartupValidator.endpointsAreSeparate()); } + /** + * Given Scenario: Phase 3 (OPENSEARCH_ONLY) with ES and OS resolving to the same address — + * legitimate, since ES is decommissioned and ES_ENDPOINTS is not required. + * Expected Result: the separation check is skipped — endpointsAreSeparate() returns true. + */ + @Test + public void test_endpointsAreSeparate_phase3_checkSkipped_returnsTrue() { + Config.setProperty(IndexConfigHelper.MigrationPhase.FLAG_KEY, 3); + Config.setProperty("ES_ENDPOINTS", new String[]{"https://localhost:9201"}); + Config.setProperty("OS_ENDPOINTS", new String[]{"https://localhost:9201"}); + + assertTrue(IndexStartupValidator.endpointsAreSeparate()); + } + /** * Given Scenario: ES and OS share the same host:port. * Expected Result: assertEndpointsSeparate throws DotRuntimeException naming the overlap.