From 565c78b2550ed24a7d907a01c8f2f5214f2e22d0 Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Mon, 6 Jul 2026 16:53:21 +0200 Subject: [PATCH 1/3] UNOMI-943: Fix 3.1.0 migration idempotency and missing resource errors Hoist rollover alias configuration to a top-level migration step so resume after failure can still configure write aliases. Fail fast in getFileWithoutComments when a bundled resource is missing. --- .../shell/migration/utils/MigrationUtils.java | 3 + .../migrate-3.1.0-01-tenantDocumentIds.groovy | 55 ++++++++++--------- .../migration/utils/MigrationUtilsTest.java | 10 ++++ 3 files changed, 41 insertions(+), 27 deletions(-) diff --git a/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java b/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java index 1bc480410..71755c590 100644 --- a/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java +++ b/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java @@ -76,6 +76,9 @@ public static String resourceAsString(BundleContext bundleContext, final String public static String getFileWithoutComments(BundleContext bundleContext, final String resource) { final URL url = bundleContext.getBundle().getResource(resource); + if (url == null) { + throw new RuntimeException("Resource not found: " + resource); + } try { // Read the entire file into a string to preserve exact line endings String fileContent; diff --git a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-3.1.0-01-tenantDocumentIds.groovy b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-3.1.0-01-tenantDocumentIds.groovy index 8a05cd1c6..a49916d83 100644 --- a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-3.1.0-01-tenantDocumentIds.groovy +++ b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-3.1.0-01-tenantDocumentIds.groovy @@ -146,34 +146,35 @@ context.performMigrationStep("3.1.0-get-all-indices", () -> { // Execute reindex MigrationUtils.reIndex(context.getHttpClient(), bundleContext, esAddress, indexName, newIndexSettings, updateScript, params, context, "3.1.0-${indexName}-update") } - - // Configure aliases for rollover indices after all reindexing is complete - // For each rollover alias, find all indices and set the latest one as write index - context.performMigrationStep("3.1.0-configure-rollover-aliases", () -> { - String configureAliasBody = MigrationUtils.resourceAsString(bundleContext, "requestBody/2.2.0/configure_alias_body.json") - - // Process each rollover item type - indexConfigs.each { itemType, config -> - if (config.useRollover) { - String alias = config.alias(indexPrefix) - // Find all indices that match the rollover pattern (e.g., context-session-000001, context-session-000002) - Set rolloverIndices = MigrationUtils.getIndexesPrefixedBy(context.getHttpClient(), esAddress, "${indexPrefix}-${itemType}-") - - if (!rolloverIndices.isEmpty()) { - // Sort indices to find the latest one (highest number) - SortedSet sortedIndices = new TreeSet<>(rolloverIndices) - String writeIndex = sortedIndices.last() - - // All indices except the last one should be read-only - SortedSet readIndices = Collections.emptySortedSet() - if (sortedIndices.size() > 1) { - readIndices = sortedIndices.headSet(sortedIndices.last()) - } - - context.printMessage("Configuring alias ${alias}: write index=${writeIndex}, read indices=${readIndices}") - MigrationUtils.configureAlias(context.getHttpClient(), esAddress, alias, writeIndex, readIndices, configureAliasBody, context) +}) + +// Configure aliases for rollover indices after all reindexing is complete. +// Top-level step so resume after failure can run alias configuration even when +// "3.1.0-get-all-indices" is already marked COMPLETED (UNOMI-943). +context.performMigrationStep("3.1.0-configure-rollover-aliases", () -> { + String configureAliasBody = MigrationUtils.resourceAsString(bundleContext, "requestBody/2.2.0/configure_alias_body.json") + + // Process each rollover item type + indexConfigs.each { itemType, config -> + if (config.useRollover) { + String alias = config.alias(indexPrefix) + // Find all indices that match the rollover pattern (e.g., context-session-000001, context-session-000002) + Set rolloverIndices = MigrationUtils.getIndexesPrefixedBy(context.getHttpClient(), esAddress, "${indexPrefix}-${itemType}-") + + if (!rolloverIndices.isEmpty()) { + // Sort indices to find the latest one (highest number) + SortedSet sortedIndices = new TreeSet<>(rolloverIndices) + String writeIndex = sortedIndices.last() + + // All indices except the last one should be read-only + SortedSet readIndices = Collections.emptySortedSet() + if (sortedIndices.size() > 1) { + readIndices = sortedIndices.headSet(sortedIndices.last()) } + + context.printMessage("Configuring alias ${alias}: write index=${writeIndex}, read indices=${readIndices}") + MigrationUtils.configureAlias(context.getHttpClient(), esAddress, alias, writeIndex, readIndices, configureAliasBody, context) } } - }) + } }) diff --git a/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java b/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java index aa1c0cb9d..96129f47c 100644 --- a/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java +++ b/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java @@ -27,6 +27,7 @@ import java.nio.charset.StandardCharsets; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.*; public class MigrationUtilsTest { @@ -199,4 +200,13 @@ public void testMixedQuotesInComments() throws Exception { String expected = "code1 code2 code3"; testCommentHandling(input, expected); } + + @Test + public void testGetFileWithoutCommentsMissingResource() { + when(bundle.getResource("missing.painless")).thenReturn(null); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> MigrationUtils.getFileWithoutComments(bundleContext, "missing.painless")); + assertEquals("Resource not found: missing.painless", ex.getMessage()); + } } \ No newline at end of file From 7c246c442dcd0410d060115b6f958b6d93305544 Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Mon, 6 Jul 2026 16:59:58 +0200 Subject: [PATCH 2/3] UNOMI-943: Document migration script authoring pitfalls Add a contributor guide covering step tracking, nested performMigrationStep anti-patterns, idempotent transforms, bundled resources, and testing. --- .../migrations/migrate-3.0-to-3.1.adoc | 2 + .../main/asciidoc/migrations/migrations.adoc | 4 + .../migrations/writing-migration-scripts.adoc | 207 ++++++++++++++++++ manual/src/main/asciidoc/shell-commands.adoc | 2 +- 4 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 manual/src/main/asciidoc/migrations/writing-migration-scripts.adoc diff --git a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc index 146f97510..92961678c 100644 --- a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc +++ b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc @@ -237,6 +237,8 @@ Before starting the migration, please ensure that: The migration from 3.0 to 3.1 is primarily a configuration and authentication update: +Contributors maintaining the Groovy scripts that implement this upgrade should follow <<_writing_migration_scripts,Writing migration scripts>> (step tracking, idempotency, and common pitfalls). + 1. **Shutdown your Apache Unomi 3.0 cluster** 2. **Update your client applications** to use the new authentication model 3. **Configure tenant-specific API keys** for your applications diff --git a/manual/src/main/asciidoc/migrations/migrations.adoc b/manual/src/main/asciidoc/migrations/migrations.adoc index 6e73631e8..b096abe8a 100644 --- a/manual/src/main/asciidoc/migrations/migrations.adoc +++ b/manual/src/main/asciidoc/migrations/migrations.adoc @@ -14,6 +14,10 @@ This section contains information and steps to migrate between major Unomi versions. +=== Writing migration scripts + +include::writing-migration-scripts.adoc[] + === V2/V3 API Compatibility Guide include::v2-v3-compatibility.adoc[] diff --git a/manual/src/main/asciidoc/migrations/writing-migration-scripts.adoc b/manual/src/main/asciidoc/migrations/writing-migration-scripts.adoc new file mode 100644 index 000000000..946874246 --- /dev/null +++ b/manual/src/main/asciidoc/migrations/writing-migration-scripts.adoc @@ -0,0 +1,207 @@ +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +[[_writing_migration_scripts]] +=== Writing migration scripts + +This section is for **contributors** who add or change Groovy migration scripts shipped in the `shell-commands` module. +Operators upgrading a cluster should follow the version-specific guides in this chapter instead. + +Migration scripts run with **Apache Unomi stopped**, via `unomi:migrate` (see <>). +They talk directly to Elasticsearch or OpenSearch over HTTP and transform persisted data in place. + +==== Script location and naming + +Bundled scripts live under: + +[source,text] +---- +tools/shell-commands/src/main/resources/META-INF/cxs/migration/ +---- + +Each file must match: + +[source,text] +---- +migrate---.groovy +---- + +* `` — three-part version the script belongs to (for example `3.1.0`) +* `` — two-digit order within that version (`00`, `01`, `05`, …) +* `` — short camelCase label (for example `tenantDocumentIds`) + +Example: `migrate-3.1.0-01-tenantDocumentIds.groovy`. + +Scripts are sorted by version, then priority, then name. +Use gaps in priority (`00`, `05`, `10`) when you may need to insert a step later. + +Operators can also drop scripts under `{karaf.data}/migration/scripts/` for one-off fixes; the same naming rules apply. + +==== Use `performMigrationStep` for every resumable unit of work + +`MigrationContext.performMigrationStep(stepKey, closure)` is the idempotency and **crash-recovery** mechanism: + +* Each `stepKey` is persisted in `{karaf.data}/migration/history.json`. +* If a step is already `COMPLETED`, it is skipped on the next run. +* If a run fails mid-step, only steps not yet marked complete are executed again. + +Rules: + +. **One logical outcome per step key** — for example “configure session rollover alias”, not “do everything in one giant step”. +. **Step keys must be stable** — changing a key after release makes resume behaviour confusing for operators who already have history files. +. **Prefer many small steps over one large step** — especially when a step can take a long time (reindex, scroll, bulk update). + +`MigrationUtils.reIndex(...)` already registers its own sub-steps (clone, recreate index, delete clone, refresh). +You do not need to wrap those again unless you add work outside `reIndex`. + +==== Pitfall: do not nest `performMigrationStep` inside another step + +*This was the root cause of https://issues.apache.org/jira/browse/UNOMI-943[UNOMI-943].* + +If step B is registered **inside** the closure of step A, then: + +* B is only evaluated while A runs. +* When A is already marked `COMPLETED` in history, A's closure is never entered again. +* B is never registered and never runs — even if B never completed. + +Real impact: rollover **write aliases** for event/session indices were skipped after a failed/resumed migration, so new writes failed silently. + +**Wrong** — alias configuration hidden inside `get-all-indices`: + +[source,groovy] +---- +context.performMigrationStep("3.1.0-get-all-indices", () -> { + // ... reindex each index ... + context.performMigrationStep("3.1.0-configure-rollover-aliases", () -> { + // configure aliases — NEVER REACHED on resume if outer step completed + }) +}) +---- + +**Right** — each tracked step at script top level: + +[source,groovy] +---- +context.performMigrationStep("3.1.0-get-all-indices", () -> { + // ... reindex each index ... +}) + +context.performMigrationStep("3.1.0-configure-rollover-aliases", () -> { + // configure aliases — runs independently on resume +}) +---- + +==== Pitfall: make data transforms safe to re-run + +Step history prevents re-running a **whole** step, but partial failure inside a step can still leave mixed state. +Design Painless scripts and update queries so a second application does not corrupt data. + +Good patterns: + +* **Check before mutate** — skip documents that already have the target shape (tenant prefix on `_id`, `tenantId` set, legacy field already renamed). +* **Copy-once fields** — only copy `nbOfVisits` → `totalNbOfVisits` when `totalNbOfVisits` is null. +* **Existence guards** — create an index or tenant document only when `MigrationUtils.indexExists(...)` is false. +* **Scoped queries** — limit `update_by_query` to documents that still match the old state (wildcard on legacy `queryBuilder` IDs, etc.). + +Example guard (document ID already tenant-prefixed): + +[source,painless] +---- +if (!ctx._id.startsWith(params.tenantId + '_') && !ctx._id.startsWith(params.systemTenantId + '_')) { + // transform ... +} +---- + +==== Pitfall: bundled resources must exist and fail clearly + +Scripts load JSON and Painless from the bundle via: + +* `MigrationUtils.resourceAsString(bundleContext, "requestBody/...")` +* `MigrationUtils.getFileWithoutComments(bundleContext, "requestBody/.../script.painless")` + +Both throw `RuntimeException("Resource not found: …")` when the path is wrong. +A missing file used to surface as an opaque `NullPointerException` in `getFileWithoutComments` (fixed in UNOMI-943). + +Keep request bodies under `tools/shell-commands/src/main/resources/requestBody//`. +After adding a file, run unit tests or a dry migration in a staging cluster — do not rely on compile-only checks. + +==== Configuration keys + +Prefer constants from `MigrationConfig` instead of raw string literals: + +[source,groovy] +---- +import static org.apache.unomi.shell.migration.service.MigrationConfig.* + +String esAddress = context.getConfigString(CONFIG_ES_ADDRESS) +String indexPrefix = context.getConfigString(INDEX_PREFIX) +String tenantId = context.getConfigString(TENANT_ID) +---- + +Legacy scripts may still use `"esAddress"` and `"indexPrefix"`; new scripts should use the constants. + +Optional settings and prompts are documented in `org.apache.unomi.migration.cfg` (see shell command help for `unomi:migrate`). + +==== Destructive operations + +Reindex and index deletion are intentional but risky: + +* `MigrationUtils.reIndex` clones the source index, deletes the original, recreates mappings, then copies data back. +* Always confirm the index name is not a `-cloned` suffix (the helper rejects reindexing clones). +* For rollover indices, configure **aliases in a separate top-level step** after all reindex work finishes. + +Log progress with `context.printMessage(...)` so operators can correlate Karaf console output with `history.json`. + +==== Testing + +[cols="1,1,2", options="header"] +|=== +| Test | Module | What it validates + +| `MigrationUtilsTest` +| `tools/shell-commands` +| Painless comment stripping, missing resource errors + +| `MigrationIT` +| `itests` +| Step history and recovery when a script fails mid-run + +| `Migrate16xToCurrentVersionIT` +| `itests` +| Full chain from 1.6.x snapshot through all bundled scripts (Elasticsearch only) +|=== + +When you add or change a 3.x script, extend `Migrate16xToCurrentVersionIT` if the change affects observable data (tenant IDs, aliases, queryBuilder IDs, profile fields, etc.). + +Run locally before opening a PR: + +[source,bash] +---- +mvn -pl tools/shell-commands -am test -Dtest=MigrationUtilsTest +---- + +Full migration ITs run in CI on the integration matrix (~2 h); they are required for script changes that affect persisted data shape. + +==== Pre-merge checklist + +Use this before submitting a migration PR: + +* [ ] File name matches `migrate---.groovy` +* [ ] Every resumable unit uses its own **top-level** `performMigrationStep` (no nesting) +* [ ] Step keys are stable and describe one outcome +* [ ] Painless / update logic is safe if applied twice inside a step +* [ ] New `requestBody/` assets are committed and referenced with correct paths +* [ ] `indexExists` / query filters guard create-only work +* [ ] Unit or IT coverage updated where behaviour is testable +* [ ] Version-specific operator doc updated if the upgrade path changes (`migrate-*-to-*.adoc`) diff --git a/manual/src/main/asciidoc/shell-commands.adoc b/manual/src/main/asciidoc/shell-commands.adoc index 94dd773b8..b3c0e3946 100644 --- a/manual/src/main/asciidoc/shell-commands.adoc +++ b/manual/src/main/asciidoc/shell-commands.adoc @@ -72,7 +72,7 @@ using the specified distribution feature name (unomi-distribution-elasticsearch |migrate |fromVersion -|This command must be used only when the Apache Unomi application is NOT STARTED. It will perform migration of the data stored in search engine using the argument fromVersion as a starting point. +|This command must be used only when the Apache Unomi application is NOT STARTED. It will perform migration of the data stored in search engine using the argument fromVersion as a starting point. Contributors writing new scripts should read <<_writing_migration_scripts,Writing migration scripts>>. |stop |n/a From 735c0a12ea5e84279cb93a1e70c02b9a29a02639 Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Mon, 6 Jul 2026 21:34:20 +0200 Subject: [PATCH 3/3] UNOMI-943: Add nested-step pitfall IT and DRY resource lookup Regression test seeds history with a completed outer step and proves nested performMigrationStep never runs on resume. Extract requireResource helper; clean up history.json in MigrationIT finally blocks. --- .../unomi/itests/migration/MigrationIT.java | 39 +++++++++++++++++++ ...migrate-12.0.0-01-nestedStepPitfall.groovy | 35 +++++++++++++++++ .../migrations/writing-migration-scripts.adoc | 2 +- .../shell/migration/utils/MigrationUtils.java | 12 +++--- 4 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 itests/src/test/resources/migration/migrate-12.0.0-01-nestedStepPitfall.groovy diff --git a/itests/src/test/java/org/apache/unomi/itests/migration/MigrationIT.java b/itests/src/test/java/org/apache/unomi/itests/migration/MigrationIT.java index b81df24f9..9c3b56262 100644 --- a/itests/src/test/java/org/apache/unomi/itests/migration/MigrationIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/migration/MigrationIT.java @@ -36,6 +36,8 @@ public class MigrationIT extends BaseIT { private static final String SUCCESS_SCRIPT_NAME = "migrate-11.0.0-01-successMigration.groovy"; private static final String FAILING_SCRIPT_RESOURCE = "migration/" + FAILING_SCRIPT_NAME; private static final String SUCCESS_SCRIPT_RESOURCE = "migration/" + SUCCESS_SCRIPT_NAME; + private static final String NESTED_STEP_SCRIPT_NAME = "migrate-12.0.0-01-nestedStepPitfall.groovy"; + private static final String NESTED_STEP_SCRIPT_RESOURCE = "migration/" + NESTED_STEP_SCRIPT_NAME; @Test public void checkMigrationRecoverySystem() throws Exception { @@ -44,6 +46,7 @@ public void checkMigrationRecoverySystem() throws Exception { LOGGER.info("Karaf data directory: {}", karafData); Path scriptsDirectory = Paths.get(karafData, "migration", "scripts"); + Path historyFsPath = Paths.get(karafData, "migration", "history.json"); Path failingScriptFsPath = Paths.get(karafData, "migration", "scripts", FAILING_SCRIPT_NAME); Path successScriptFsPath = Paths.get(karafData, "migration", "scripts", SUCCESS_SCRIPT_NAME); @@ -74,6 +77,42 @@ public void checkMigrationRecoverySystem() throws Exception { } finally { Files.deleteIfExists(failingScriptFsPath); Files.deleteIfExists(successScriptFsPath); + Files.deleteIfExists(historyFsPath); + } + } + + /** + * Regression test for UNOMI-943: a step registered inside another step's closure + * is never (re-)registered once the outer step is already marked COMPLETED in history. + * We simulate that prior state directly by seeding history.json, then verify that the + * nested step never runs while a sibling top-level step still does. + */ + @Test + public void checkNestedMigrationStepPitfall() throws Exception { + String karafData = super.karafData(); + Path scriptFsPath = Paths.get(karafData, "migration", "scripts", NESTED_STEP_SCRIPT_NAME); + Path historyFsPath = Paths.get(karafData, "migration", "history.json"); + + try { + Files.createDirectories(scriptFsPath.getParent()); + Files.write(scriptFsPath, bundleResourceAsString(NESTED_STEP_SCRIPT_RESOURCE).getBytes(StandardCharsets.UTF_8)); + + // Simulate "outer-step" already COMPLETED from a previous run, before "nested-step" existed. + Files.write(historyFsPath, "{\"outer-step\":\"COMPLETED\"}".getBytes(StandardCharsets.UTF_8)); + + String result = executeCommand("unomi:migrate 11.0.0 true"); + System.out.println("Nested migration step pitfall result:"); + System.out.println(result); + + // Outer step is already COMPLETED, so its closure -- including the nested step + // registration -- is never re-entered. This is the bug UNOMI-943 fixed by hoisting. + Assert.assertFalse(result.contains("inside outer-step")); + Assert.assertFalse(result.contains("inside nested-step")); + // A sibling top-level step is unaffected and still runs. + Assert.assertTrue(result.contains("inside sibling-step")); + } finally { + Files.deleteIfExists(scriptFsPath); + Files.deleteIfExists(historyFsPath); } } } diff --git a/itests/src/test/resources/migration/migrate-12.0.0-01-nestedStepPitfall.groovy b/itests/src/test/resources/migration/migrate-12.0.0-01-nestedStepPitfall.groovy new file mode 100644 index 000000000..f5a75877f --- /dev/null +++ b/itests/src/test/resources/migration/migrate-12.0.0-01-nestedStepPitfall.groovy @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.apache.unomi.shell.migration.service.MigrationContext + +// Fixture for MigrationIT#checkNestedMigrationStepPitfall. +// Reproduces the UNOMI-943 anti-pattern: a step nested inside another step's +// closure is never (re-)registered once the outer step is already COMPLETED. + +MigrationContext context = migrationContext + +context.performMigrationStep("outer-step", () -> { + context.printMessage("inside outer-step") + context.performMigrationStep("nested-step", () -> { + context.printMessage("inside nested-step") + }) +}) + +context.performMigrationStep("sibling-step", () -> { + context.printMessage("inside sibling-step") +}) diff --git a/manual/src/main/asciidoc/migrations/writing-migration-scripts.adoc b/manual/src/main/asciidoc/migrations/writing-migration-scripts.adoc index 946874246..b0615bb5d 100644 --- a/manual/src/main/asciidoc/migrations/writing-migration-scripts.adoc +++ b/manual/src/main/asciidoc/migrations/writing-migration-scripts.adoc @@ -175,7 +175,7 @@ Log progress with `context.printMessage(...)` so operators can correlate Karaf c | `MigrationIT` | `itests` -| Step history and recovery when a script fails mid-run +| Step history recovery (`checkMigrationRecoverySystem`); nested-step pitfall guard (`checkNestedMigrationStepPitfall`, UNOMI-943) | `Migrate16xToCurrentVersionIT` | `itests` diff --git a/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java b/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java index 71755c590..072d8ba9f 100644 --- a/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java +++ b/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java @@ -62,11 +62,16 @@ public static void bulkUpdate(CloseableHttpClient httpClient, String url, String HttpUtils.executePostRequest(httpClient, url, jsonData, null); } - public static String resourceAsString(BundleContext bundleContext, final String resource) { + private static URL requireResource(BundleContext bundleContext, final String resource) { final URL url = bundleContext.getBundle().getResource(resource); if (url == null) { throw new RuntimeException("Resource not found: " + resource); } + return url; + } + + public static String resourceAsString(BundleContext bundleContext, final String resource) { + final URL url = requireResource(bundleContext, resource); try (InputStream stream = url.openStream()) { return IOUtils.toString(stream, StandardCharsets.UTF_8); } catch (final Exception e) { @@ -75,10 +80,7 @@ public static String resourceAsString(BundleContext bundleContext, final String } public static String getFileWithoutComments(BundleContext bundleContext, final String resource) { - final URL url = bundleContext.getBundle().getResource(resource); - if (url == null) { - throw new RuntimeException("Resource not found: " + resource); - } + final URL url = requireResource(bundleContext, resource); try { // Read the entire file into a string to preserve exact line endings String fileContent;