From 1cfb1382647e85a9638bb3be5885bba129c97565 Mon Sep 17 00:00:00 2001 From: Mateusz Zan Date: Tue, 21 Apr 2026 09:42:47 +0100 Subject: [PATCH 1/3] fix effector behaviour where yaml list is passed as input --- .../util/core/internal/TypeCoercionsTest.java | 32 ++++++++++++++++++- .../coerce/CommonAdaptorTypeCoercions.java | 21 ++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/core/src/test/java/org/apache/brooklyn/util/core/internal/TypeCoercionsTest.java b/core/src/test/java/org/apache/brooklyn/util/core/internal/TypeCoercionsTest.java index c9468f1431..65a40db56d 100644 --- a/core/src/test/java/org/apache/brooklyn/util/core/internal/TypeCoercionsTest.java +++ b/core/src/test/java/org/apache/brooklyn/util/core/internal/TypeCoercionsTest.java @@ -355,11 +355,41 @@ public void testKeyEqualsOrColonValueWithoutBracesStringToMapCoercion() { public void testYamlMapsDontGoTooFarWhenWantingListOfString() { List s = TypeCoercions.coerce("[ a: 1, b: 2 ]", List.class); assertEquals(s, ImmutableList.of(MutableMap.of("a", 1), MutableMap.of("b", 2))); - + s = TypeCoercions.coerce("[ a: 1, b : 2 ]", new TypeToken>() {}); assertEquals(s, ImmutableList.of("a: 1", "b : 2")); } + @SuppressWarnings("serial") + @Test + public void testYamlBlockListCoercionToStringList() { + // YAML block list syntax should be parsed correctly for List + List s = TypeCoercions.coerce("- a\n- b", new TypeToken>() {}); + assertEquals(s, ImmutableList.of("a", "b")); + + s = TypeCoercions.coerce("- a\n- b\n- c", new TypeToken>() {}); + assertEquals(s, ImmutableList.of("a", "b", "c")); + + // single item + s = TypeCoercions.coerce("- a", new TypeToken>() {}); + assertEquals(s, ImmutableList.of("a")); + + // multi-word items + s = TypeCoercions.coerce("- hello world\n- foo bar", new TypeToken>() {}); + assertEquals(s, ImmutableList.of("hello world", "foo bar")); + + // numeric items should coerce to strings + s = TypeCoercions.coerce("- 1\n- 2", new TypeToken>() {}); + assertEquals(s, ImmutableList.of("1", "2")); + + // comma-separated and bracket forms still work for List + s = TypeCoercions.coerce("a, b, c", new TypeToken>() {}); + assertEquals(s, ImmutableList.of("a", "b", "c")); + + s = TypeCoercions.coerce("[a, b]", new TypeToken>() {}); + assertEquals(s, ImmutableList.of("a", "b")); + } + @Test public void testURItoStringCoercion() { String s = TypeCoercions.coerce(URI.create("http://localhost:1234/"), String.class); diff --git a/utils/common/src/main/java/org/apache/brooklyn/util/javalang/coerce/CommonAdaptorTypeCoercions.java b/utils/common/src/main/java/org/apache/brooklyn/util/javalang/coerce/CommonAdaptorTypeCoercions.java index a792c70880..563fe125ba 100644 --- a/utils/common/src/main/java/org/apache/brooklyn/util/javalang/coerce/CommonAdaptorTypeCoercions.java +++ b/utils/common/src/main/java/org/apache/brooklyn/util/javalang/coerce/CommonAdaptorTypeCoercions.java @@ -441,8 +441,25 @@ public Maybe tryCoerce(Object input, TypeToken type) { Maybe resultM = null; Collection result = null; if (parameters.length==1 && TypeTokens.isAssignableFromRaw(CharSequence.class, parameters[0])) { - // for list of strings, use special parse - result = JavaStringEscapes.unwrapJsonishListStringIfPossible(inputS); + // for list of strings: if input uses YAML block list syntax ("- item" lines), parse + // it directly with YAML (wrapping in brackets kills the block sequence markers). + // Only adopt the YAML result when all elements are simple values — this preserves + // the behaviour that "[ a: 1, b: 2 ]" stays as ["a: 1", "b: 2"] rather than + // being coerced from map objects to their toString representations. + if (inputS.trim().startsWith("- ")) { + try { + Object yamlDoc = Iterables.getOnlyElement(Yamls.parseAll(inputS)); + if (yamlDoc instanceof List && + ((List) yamlDoc).stream().noneMatch(x -> x instanceof Map || x instanceof Collection)) { + result = (Collection) yamlDoc; + } + } catch (Exception e) { + Exceptions.propagateIfFatal(e); + } + } + if (result == null) { + result = JavaStringEscapes.unwrapJsonishListStringIfPossible(inputS); + } } else { // any other type, use YAMLish parse resultM = JavaStringEscapes.tryUnwrapJsonishList(inputS); From 5bd75ce8705dc1d2e6c650960adcfccf90458061 Mon Sep 17 00:00:00 2001 From: Mateusz Zan Date: Tue, 21 Apr 2026 09:44:22 +0100 Subject: [PATCH 2/3] add capability in logbook to filter based on class/logger name --- .../util/core/logbook/LogBookQueryParams.java | 11 ++++ .../util/core/logbook/file/FileLogStore.java | 5 ++ .../opensearch/OpenSearchLogStore.java | 5 ++ .../core/logbook/file/FileLogStoreTest.java | 52 +++++++++++++++++++ .../opensearch/OpenSearchLogStoreTest.java | 25 +++++++++ 5 files changed, 98 insertions(+) diff --git a/core/src/main/java/org/apache/brooklyn/util/core/logbook/LogBookQueryParams.java b/core/src/main/java/org/apache/brooklyn/util/core/logbook/LogBookQueryParams.java index d726b57a73..704eb48f33 100644 --- a/core/src/main/java/org/apache/brooklyn/util/core/logbook/LogBookQueryParams.java +++ b/core/src/main/java/org/apache/brooklyn/util/core/logbook/LogBookQueryParams.java @@ -50,6 +50,9 @@ public class LogBookQueryParams { private String entityId; + /** The logger/class name prefix to filter log items by, e.g. "o.a.b.SSH" */ + private String loggerName; + public Integer getNumberOfItems() { return numberOfItems; } @@ -121,4 +124,12 @@ public String getEntityId() { public void setEntityId(String entityId) { this.entityId = entityId; } + + public String getLoggerName() { + return loggerName; + } + + public void setLoggerName(String loggerName) { + this.loggerName = loggerName; + } } diff --git a/core/src/main/java/org/apache/brooklyn/util/core/logbook/file/FileLogStore.java b/core/src/main/java/org/apache/brooklyn/util/core/logbook/file/FileLogStore.java index 375ec0781b..5b37b7773e 100644 --- a/core/src/main/java/org/apache/brooklyn/util/core/logbook/file/FileLogStore.java +++ b/core/src/main/java/org/apache/brooklyn/util/core/logbook/file/FileLogStore.java @@ -196,6 +196,11 @@ public List query(LogBookQueryParams params) { if (Strings.isBlank(brooklynLogEntry.getMessage()) || !brooklynLogEntry.getMessage().contains(params.getSearchPhrase())) return false; } + // Check logger/class name prefix. + if (Strings.isNonBlank(params.getLoggerName())) { + if (Strings.isBlank(brooklynLogEntry.getClazz()) || !brooklynLogEntry.getClazz().startsWith(params.getLoggerName())) return false; + } + return true; }; diff --git a/core/src/main/java/org/apache/brooklyn/util/core/logbook/opensearch/OpenSearchLogStore.java b/core/src/main/java/org/apache/brooklyn/util/core/logbook/opensearch/OpenSearchLogStore.java index 4a98893e8a..9c76a662d4 100644 --- a/core/src/main/java/org/apache/brooklyn/util/core/logbook/opensearch/OpenSearchLogStore.java +++ b/core/src/main/java/org/apache/brooklyn/util/core/logbook/opensearch/OpenSearchLogStore.java @@ -305,6 +305,11 @@ private ImmutableMap buildQuery(LogBookQueryParams params) { queryBoolMustListBuilder.add(buildMatchPhraseOf("message", params.getSearchPhrase())); } + // Apply logger/class name prefix. + if (Strings.isNonBlank(params.getLoggerName())) { + queryBoolMustListBuilder.add(ImmutableMap.of("prefix", ImmutableMap.of("class", params.getLoggerName()))); + } + ImmutableList queryBoolMustList = queryBoolMustListBuilder.build(); if (queryBoolMustList.isEmpty()) { diff --git a/core/src/test/java/org/apache/brooklyn/util/core/logbook/file/FileLogStoreTest.java b/core/src/test/java/org/apache/brooklyn/util/core/logbook/file/FileLogStoreTest.java index 4b4a7a64da..969cf6ef84 100644 --- a/core/src/test/java/org/apache/brooklyn/util/core/logbook/file/FileLogStoreTest.java +++ b/core/src/test/java/org/apache/brooklyn/util/core/logbook/file/FileLogStoreTest.java @@ -496,6 +496,58 @@ public void testQueryLogSampleWithRecursionLimitOne() { assertTrue(brooklynLogEntries.stream().anyMatch(Predicates.not(e -> e.getTaskId().equals(logBookQueryParams.getTaskId())))); } + @Test + public void testQueryLogSampleWithLoggerName() { + File file = new File(Objects.requireNonNull(getClass().getClassLoader().getResource(JAVA_LOG_SAMPLE_PATH)).getFile()); + mgmt = LocalManagementContextForTests.newInstance(); + mgmt.getBrooklynProperties().put(LOGBOOK_LOG_STORE_PATH.getName(), file.getAbsolutePath()); + LogBookQueryParams logBookQueryParams = new LogBookQueryParams(); + logBookQueryParams.setNumberOfItems(1000); + logBookQueryParams.setTail(false); + logBookQueryParams.setLevels(ImmutableList.of()); + logBookQueryParams.setLoggerName("i.c.b"); // matches all AbstractToscaYamlConverter entries + FileLogStore fileLogStore = new FileLogStore(mgmt); + List brooklynLogEntries = fileLogStore.query(logBookQueryParams); + + assertEquals(4, brooklynLogEntries.size()); + assertTrue(brooklynLogEntries.stream().allMatch(e -> e.getClazz().startsWith("i.c.b"))); + } + + @Test + public void testQueryLogSampleWithLoggerNameAndPhrase() { + File file = new File(Objects.requireNonNull(getClass().getClassLoader().getResource(JAVA_LOG_SAMPLE_PATH)).getFile()); + mgmt = LocalManagementContextForTests.newInstance(); + mgmt.getBrooklynProperties().put(LOGBOOK_LOG_STORE_PATH.getName(), file.getAbsolutePath()); + LogBookQueryParams logBookQueryParams = new LogBookQueryParams(); + logBookQueryParams.setNumberOfItems(1000); + logBookQueryParams.setTail(false); + logBookQueryParams.setLevels(ImmutableList.of()); + logBookQueryParams.setLoggerName("i.c.b"); + logBookQueryParams.setSearchPhrase("testing"); + FileLogStore fileLogStore = new FileLogStore(mgmt); + List brooklynLogEntries = fileLogStore.query(logBookQueryParams); + + assertEquals(2, brooklynLogEntries.size()); + assertTrue(brooklynLogEntries.stream().allMatch(e -> e.getClazz().startsWith("i.c.b"))); + assertTrue(brooklynLogEntries.stream().allMatch(e -> e.getMessage().contains("testing"))); + } + + @Test + public void testQueryLogSampleWithNonMatchingLoggerName() { + File file = new File(Objects.requireNonNull(getClass().getClassLoader().getResource(JAVA_LOG_SAMPLE_PATH)).getFile()); + mgmt = LocalManagementContextForTests.newInstance(); + mgmt.getBrooklynProperties().put(LOGBOOK_LOG_STORE_PATH.getName(), file.getAbsolutePath()); + LogBookQueryParams logBookQueryParams = new LogBookQueryParams(); + logBookQueryParams.setNumberOfItems(1000); + logBookQueryParams.setTail(false); + logBookQueryParams.setLevels(ImmutableList.of()); + logBookQueryParams.setLoggerName("o.a.b.SSH"); // no entries with this class prefix + FileLogStore fileLogStore = new FileLogStore(mgmt); + List brooklynLogEntries = fileLogStore.query(logBookQueryParams); + + assertEquals(0, brooklynLogEntries.size()); + } + private LogBookQueryParams newQueryParams(boolean recursive) { LogBookQueryParams params = new LogBookQueryParams(); params.setNumberOfItems(5); // Request first five only diff --git a/core/src/test/java/org/apache/brooklyn/util/core/logbook/opensearch/OpenSearchLogStoreTest.java b/core/src/test/java/org/apache/brooklyn/util/core/logbook/opensearch/OpenSearchLogStoreTest.java index 5517111361..393d0df888 100644 --- a/core/src/test/java/org/apache/brooklyn/util/core/logbook/opensearch/OpenSearchLogStoreTest.java +++ b/core/src/test/java/org/apache/brooklyn/util/core/logbook/opensearch/OpenSearchLogStoreTest.java @@ -170,6 +170,31 @@ public void queryWithEntityIdAndPhrase() { assertEquals(query, "{\"sort\":{\"timestamp\":\"asc\"},\"size\":10,\"query\":{\"bool\":{\"must\":[{\"bool\":{\"should\":[{\"match_phrase\":{\"entityIds\":\"entityIdxx\"}},{\"match_phrase\":{\"message\":\"entityIdxx\"}}]}},{\"match_phrase\":{\"message\":\"some phrase\"}}]}}}"); } + @Test + public void queryWithLoggerName() { + OpenSearchLogStore cut = new OpenSearchLogStore(); + LogBookQueryParams p = new LogBookQueryParams(); + p.setNumberOfItems(10); + p.setTail(false); + p.setLevels(ImmutableList.of()); + p.setLoggerName("o.a.b.SSH"); + String query = cut.getJsonQuery(p); + assertEquals(query, "{\"sort\":{\"timestamp\":\"asc\"},\"size\":10,\"query\":{\"bool\":{\"must\":[{\"prefix\":{\"class\":\"o.a.b.SSH\"}}]}}}"); + } + + @Test + public void queryWithLoggerNameAndPhrase() { + OpenSearchLogStore cut = new OpenSearchLogStore(); + LogBookQueryParams p = new LogBookQueryParams(); + p.setNumberOfItems(10); + p.setTail(false); + p.setLevels(ImmutableList.of()); + p.setLoggerName("o.a.b.SSH"); + p.setSearchPhrase("some phrase"); + String query = cut.getJsonQuery(p); + assertEquals(query, "{\"sort\":{\"timestamp\":\"asc\"},\"size\":10,\"query\":{\"bool\":{\"must\":[{\"match_phrase\":{\"message\":\"some phrase\"}},{\"prefix\":{\"class\":\"o.a.b.SSH\"}}]}}}"); + } + @Test public void queryWithTaskIdAndPhrase() { OpenSearchLogStore cut = new OpenSearchLogStore(); From 01f7786b0ae0e1765592e4ed40882af1bf19d925 Mon Sep 17 00:00:00 2001 From: Mateusz Zan Date: Tue, 21 Apr 2026 09:44:59 +0100 Subject: [PATCH 3/3] fix behaviour when a bundle is updated and needs a restart for it to take effect on deployed applications --- .../internal/CatalogInitialization.java | 24 ++++- .../ha/BrooklynBomOsgiArchiveInstaller.java | 66 +++++++++++- .../util/core/task/AutoFlagsCallbackTest.java | 4 +- ...rooklynLauncherUpgradeCatalogOsgiTest.java | 101 +++++++++++++++++- 4 files changed, 188 insertions(+), 7 deletions(-) diff --git a/core/src/main/java/org/apache/brooklyn/core/catalog/internal/CatalogInitialization.java b/core/src/main/java/org/apache/brooklyn/core/catalog/internal/CatalogInitialization.java index 75dcb0bd5d..55d48afb41 100644 --- a/core/src/main/java/org/apache/brooklyn/core/catalog/internal/CatalogInitialization.java +++ b/core/src/main/java/org/apache/brooklyn/core/catalog/internal/CatalogInitialization.java @@ -310,12 +310,30 @@ public void installPersistedBundles(PersistedCatalogState persistedState, Runnab } } + /** + * Rescans all OSGi bundles for upgrade headers and updates the {@link CatalogUpgrades} stored in the + * management context. Called at runtime after a bundle with upgrade headers is installed at runtime + * (not during rebind), once the bundle's types are loaded in the type registry. + */ + public void rescanBundleUpgradesForRuntime() { + Maybe maybesOsgiManager = managementContext.getOsgiManager(); + if (maybesOsgiManager.isAbsent()) return; + OsgiManager osgiManager = maybesOsgiManager.get(); + BundleContext bundleContext = osgiManager.getFramework().getBundleContext(); + RebindLogger runtimeLogger = new RebindLogger() { + @Override public void debug(String msg, Object... args) { log.debug(msg, args); } + @Override public void info(String msg, Object... args) { log.info(msg, args); } + }; + CatalogUpgrades freshUpgrades = catalogUpgradeScanner.scan(osgiManager, bundleContext, runtimeLogger); + CatalogUpgrades.storeInManagementContext(freshUpgrades, managementContext); + } + /** * Populates the initial catalog, but not via an official code-path. - * - * Expected to be called only during tests, where the test has not gone through the same + * + * Expected to be called only during tests, where the test has not gone through the same * management-context lifecycle as is done in BasicLauncher. - * + * * Subsequent calls will fail to things like {@link #populateInitialCatalogOnly()} or * {@link #populateInitialAndPersistedCatalog(ManagementNodeState, PersistedCatalogState, RebindExceptionHandler, RebindLogger)}. */ diff --git a/core/src/main/java/org/apache/brooklyn/core/mgmt/ha/BrooklynBomOsgiArchiveInstaller.java b/core/src/main/java/org/apache/brooklyn/core/mgmt/ha/BrooklynBomOsgiArchiveInstaller.java index e6b89ca141..287b7d40fe 100644 --- a/core/src/main/java/org/apache/brooklyn/core/mgmt/ha/BrooklynBomOsgiArchiveInstaller.java +++ b/core/src/main/java/org/apache/brooklyn/core/mgmt/ha/BrooklynBomOsgiArchiveInstaller.java @@ -34,7 +34,9 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import javax.annotation.Nullable; +import org.apache.brooklyn.api.entity.Entity; import org.apache.brooklyn.api.mgmt.ManagementContext; +import org.apache.brooklyn.core.objs.BrooklynObjectInternal; import org.apache.brooklyn.api.typereg.ManagedBundle; import org.apache.brooklyn.api.typereg.RegisteredType; import org.apache.brooklyn.core.BrooklynVersion; @@ -145,7 +147,55 @@ public void setValidateTypes(boolean validateTypes) { private ManagementContextInternal mgmt() { return (ManagementContextInternal) osgiManager.getManagementContext(); } - + + private void removeSupersededBundlesAfterUpgrade(ManagedBundle installedBundle) { + // Migrate running entities to v2 catalog context BEFORE removing v1's OSGi bundle, + // so classpath:// resource lookups (e.g. scripts) use v2's classloader. + migrateRunningEntitiesToUpgradedCatalogItems(); + + Collection snapshot = new ArrayList<>(osgiManager.getManagedBundles().values()); + for (ManagedBundle mb : snapshot) { + if (mb.getVersionedName().equals(installedBundle.getVersionedName())) continue; + Maybe replacement = CatalogUpgrades.tryGetBundleForcedReplaced(mgmt(), mb.getVersionedName()); + if (replacement.isPresent()) { + log.info("Bundle {} superseded by {} at runtime, removing", mb.getVersionedName(), replacement.get()); + osgiManager.uninstallUploadedBundle(mb); + } + } + } + + private void migrateRunningEntitiesToUpgradedCatalogItems() { + CatalogUpgrades upgrades = CatalogUpgrades.getFromManagementContext(mgmt()); + Collection allEntities = MutableList.copyOf(mgmt().getEntityManager().getEntities()); + for (Entity entity : allEntities) { + String oldCatalogId = entity.getCatalogItemId(); + if (oldCatalogId == null) continue; + + VersionedName oldVName; + try { oldVName = VersionedName.fromString(oldCatalogId); } + catch (Exception e) { continue; } + + Set targets = upgrades.getUpgradesForType(oldVName); + if (targets.isEmpty()) continue; + + String newCatalogId = targets.iterator().next().toOsgiString(); + + List newSearchPath = new ArrayList<>(); + for (String pathEntry : entity.getCatalogItemIdSearchPath()) { + try { + VersionedName pathVName = VersionedName.fromString(pathEntry); + Set pathTargets = upgrades.getUpgradesForType(pathVName); + newSearchPath.add(pathTargets.isEmpty() ? pathEntry : pathTargets.iterator().next().toOsgiString()); + } catch (Exception e) { + newSearchPath.add(pathEntry); + } + } + + log.info("Migrating entity {} catalog context at runtime from {} to {}", entity, oldCatalogId, newCatalogId); + ((BrooklynObjectInternal) entity).setCatalogItemIdAndSearchPath(newCatalogId, newSearchPath); + } + } + private synchronized void init() { if (result!=null) { if (zipFile!=null || zipIn==null) return; @@ -836,6 +886,20 @@ public void run() { throw Exceptions.propagate(e); } } + + // Process upgrade headers (Brooklyn-Catalog-Force-Remove-Bundles / + // Brooklyn-Catalog-Upgrade-For-Bundles) from the newly installed bundle at runtime. + // Bundle types are now in the type registry so the upgrade scanner can correctly + // build upgradesProvidedByTypes. Skip during rebind — that path uses installPersistedBundles. + if (!Boolean.TRUE.equals(result.rebinding) && result.bundle != null) { + java.util.Dictionary newBundleHeaders = result.bundle.getHeaders(); + if (newBundleHeaders != null && ( + newBundleHeaders.get(BundleUpgradeParser.MANIFEST_HEADER_FORCE_REMOVE_BUNDLES) != null || + newBundleHeaders.get(BundleUpgradeParser.MANIFEST_HEADER_UPGRADE_FOR_BUNDLES) != null)) { + mgmt().getCatalogInitialization().rescanBundleUpgradesForRuntime(); + removeSupersededBundlesAfterUpgrade(result.getMetadata()); + } + } } }; if (deferredStart) { diff --git a/core/src/test/java/org/apache/brooklyn/util/core/task/AutoFlagsCallbackTest.java b/core/src/test/java/org/apache/brooklyn/util/core/task/AutoFlagsCallbackTest.java index 37d23615ba..bf7eb598e6 100644 --- a/core/src/test/java/org/apache/brooklyn/util/core/task/AutoFlagsCallbackTest.java +++ b/core/src/test/java/org/apache/brooklyn/util/core/task/AutoFlagsCallbackTest.java @@ -55,8 +55,8 @@ public void testCalledInOrder() { Asserts.assertThat(depth.get(), x -> x>=0); app.start(null); - // but by the time start completes it should be back to 0 - Asserts.assertEquals(depth.get(), 0); + // sensor tasks triggered during start may complete slightly after start() returns; wait for them + Asserts.eventually(() -> depth.get(), x -> x == 0); Entities.submit(app, Tasks.create("test1", () -> { log.info("running test 1" + " / " + Tasks.current().getId()); diff --git a/launcher/src/test/java/org/apache/brooklyn/launcher/BrooklynLauncherUpgradeCatalogOsgiTest.java b/launcher/src/test/java/org/apache/brooklyn/launcher/BrooklynLauncherUpgradeCatalogOsgiTest.java index a3f4c32bbd..040c3d1fc7 100644 --- a/launcher/src/test/java/org/apache/brooklyn/launcher/BrooklynLauncherUpgradeCatalogOsgiTest.java +++ b/launcher/src/test/java/org/apache/brooklyn/launcher/BrooklynLauncherUpgradeCatalogOsgiTest.java @@ -406,7 +406,106 @@ public void testForciblyRemovedBundleNotAddedWhenReferencedByMvnUrl() throws Exc assertEquals(resultWithoutForceCode, ResultCode.IGNORING_BUNDLE_FORCIBLY_REMOVED); assertEquals(resultWithoutForce.get().getMetadata().getVersionedName(), bundleV2.getVersionedName()); assertTrue(resultWithoutForceMessage.contains("Bundle "+bundleV1.getVersionedName()+" forcibly removed, upgraded to 2.0.0"), "msg="+resultWithoutForceMessage); - + + launcher.terminate(); + } + + // Upgrade headers must be processed when br catalog add is used at runtime (no AMP restart). + // This tests that installing v2 with upgrade headers immediately removes v1 and builds + // type upgrade mappings so new deployments with the old type id resolve to v2. + @Test + public void testForceUpgradeBundleAtRuntime() throws Exception { + VersionedName one_1_0_0 = VersionedName.fromString("one:1.0.0"); + VersionedName one_2_0_0 = VersionedName.fromString("one:2.0.0"); + + BundleFile bundleV1 = bundleBuilder() + .name("org.example.testForceUpgradeBundleAtRuntime", "1.0.0") + .catalogBom(ImmutableList.of(), ImmutableSet.of(one_1_0_0)) + .build(); + + BundleFile bundleV2 = bundleBuilder() + .name(bundleV1.getVersionedName().getSymbolicName(), "2.0.0") + .catalogBom(ImmutableList.of(), ImmutableSet.of(one_2_0_0)) + .manifestLines(ImmutableMap.builder() + .put(MANIFEST_HEADER_FORCE_REMOVE_BUNDLES, "\"*\"") + .put(MANIFEST_HEADER_UPGRADE_FOR_BUNDLES, "\"*\"") + .build()) + .build(); + + // Only v1 starts in persisted state; v2 is installed at runtime (simulating `br catalog add v2`). + newPersistedStateInitializer() + .bundle(bundleV1) + .initState(); + + BrooklynLauncher launcher = newLauncherForTests(CATALOG_EMPTY_INITIAL); + launcher.start(); + assertCatalogConsistsOfIds(launcher, ImmutableList.of(one_1_0_0)); + + // Install v2 at runtime — no restart. + ReferenceWithError installResult = installBundle(launcher, bundleV2.getFile(), false); + Assert.assertEquals(installResult.get().getCode(), ResultCode.INSTALLED_NEW_BUNDLE); + + // v1 should be gone, v2 should be present. + assertCatalogConsistsOfIds(launcher, ImmutableList.of(one_2_0_0)); + assertManagedBundle(launcher, bundleV2.getVersionedName(), ImmutableSet.of(one_2_0_0)); + assertNotManagedBundle(launcher, bundleV1.getVersionedName()); + + // Deploying with old type id should resolve to v2 via the runtime type upgrade mapping. + Application app = createAndStartApplication(launcher.getManagementContext(), + "services: [ { type: 'one:1.0.0' } ]"); + Entity one = Iterables.getOnlyElement(app.getChildren()); + Assert.assertEquals(one.getCatalogItemId(), "one:2.0.0"); + + launcher.terminate(); + } + + // Regression test: running entities deployed against v1 must have their catalogItemId migrated + // to v2 when v2 is installed at runtime, so that classpath:// resources are resolved from v2's + // OSGi bundle (not the removed v1 bundle). + @Test + public void testForceUpgradeBundleAtRuntimeMigratesRunningEntities() throws Exception { + VersionedName one_1_0_0 = VersionedName.fromString("one:1.0.0"); + VersionedName one_2_0_0 = VersionedName.fromString("one:2.0.0"); + + BundleFile bundleV1 = bundleBuilder() + .name("org.example.testMigratesRunningEntities", "1.0.0") + .catalogBom(ImmutableList.of(), ImmutableSet.of(one_1_0_0)) + .build(); + + BundleFile bundleV2 = bundleBuilder() + .name(bundleV1.getVersionedName().getSymbolicName(), "2.0.0") + .catalogBom(ImmutableList.of(), ImmutableSet.of(one_2_0_0)) + .manifestLines(ImmutableMap.builder() + .put(MANIFEST_HEADER_FORCE_REMOVE_BUNDLES, "\"*\"") + .put(MANIFEST_HEADER_UPGRADE_FOR_BUNDLES, "\"*\"") + .build()) + .build(); + + newPersistedStateInitializer() + .bundle(bundleV1) + .initState(); + + BrooklynLauncher launcher = newLauncherForTests(CATALOG_EMPTY_INITIAL); + launcher.start(); + + // Deploy an app using v1 type — entity is running with v1 catalogItemId. + Application app = createAndStartApplication(launcher.getManagementContext(), + "services: [ { type: 'one:1.0.0' } ]"); + Entity one = Iterables.getOnlyElement(app.getChildren()); + Assert.assertEquals(one.getCatalogItemId(), "one:1.0.0"); + + // Install v2 at runtime — no restart. + ReferenceWithError installResult = installBundle(launcher, bundleV2.getFile(), false); + Assert.assertEquals(installResult.get().getCode(), ResultCode.INSTALLED_NEW_BUNDLE); + + // v1 gone, v2 present. + assertCatalogConsistsOfIds(launcher, ImmutableList.of(one_2_0_0)); + assertNotManagedBundle(launcher, bundleV1.getVersionedName()); + + // The previously running entity must have its catalogItemId migrated to v2, + // so its classloader resolves classpath:// resources from v2's bundle. + Assert.assertEquals(one.getCatalogItemId(), "one:2.0.0"); + launcher.terminate(); } }