diff --git a/src/generated/java/io/neonbee/config/NeonBeeConfigConverter.java b/src/generated/java/io/neonbee/config/NeonBeeConfigConverter.java index 3e0de603a..72e9f0fe4 100644 --- a/src/generated/java/io/neonbee/config/NeonBeeConfigConverter.java +++ b/src/generated/java/io/neonbee/config/NeonBeeConfigConverter.java @@ -19,6 +19,12 @@ public class NeonBeeConfigConverter { static void fromJson(Iterable> json, NeonBeeConfig obj) { for (java.util.Map.Entry member : json) { switch (member.getKey()) { + case "bootDeploymentHandling": + if (member.getValue() instanceof String) { + obj.setBootDeploymentHandling( + io.neonbee.config.NeonBeeConfig.BootDeploymentHandling.valueOf((String) member.getValue())); + } + break; case "defaultThreadingModel": if (member.getValue() instanceof String) { obj.setDefaultThreadingModel(io.vertx.core.ThreadingModel.valueOf((String) member.getValue())); @@ -116,6 +122,9 @@ static void toJson(NeonBeeConfig obj, JsonObject json) { } static void toJson(NeonBeeConfig obj, java.util.Map json) { + if (obj.getBootDeploymentHandling() != null) { + json.put("bootDeploymentHandling", obj.getBootDeploymentHandling().name()); + } if (obj.getDefaultThreadingModel() != null) { json.put("defaultThreadingModel", obj.getDefaultThreadingModel().name()); } diff --git a/src/main/java/io/neonbee/NeonBee.java b/src/main/java/io/neonbee/NeonBee.java index 506ab0b24..d62955f9e 100644 --- a/src/main/java/io/neonbee/NeonBee.java +++ b/src/main/java/io/neonbee/NeonBee.java @@ -1,5 +1,7 @@ package io.neonbee; +import static io.neonbee.config.NeonBeeConfig.BootDeploymentHandling.FAIL_ON_ERROR; +import static io.neonbee.config.NeonBeeConfig.BootDeploymentHandling.KEEP_PARTIAL; import static io.neonbee.internal.deploy.DeployableModule.fromJar; import static io.neonbee.internal.deploy.DeployableVerticle.fromClass; import static io.neonbee.internal.deploy.DeployableVerticle.fromVerticle; @@ -44,6 +46,7 @@ import io.neonbee.cluster.ClusterManagerFactory; import io.neonbee.config.HealthConfig; import io.neonbee.config.NeonBeeConfig; +import io.neonbee.config.NeonBeeConfig.BootDeploymentHandling; import io.neonbee.config.ServerConfig; import io.neonbee.data.DataException; import io.neonbee.data.DataQuery; @@ -102,6 +105,7 @@ import io.vertx.core.eventbus.EventBusOptions; import io.vertx.core.eventbus.MessageCodec; import io.vertx.core.impl.ConcurrentHashSet; +import io.vertx.core.impl.VertxInternal; import io.vertx.core.json.Json; import io.vertx.core.json.JsonObject; import io.vertx.core.net.PfxOptions; @@ -566,21 +570,25 @@ private Future deploySystemVerticles() { List> requiredVerticles = new ArrayList<>(); requiredVerticles.add(fromClass(vertx, ConsolidationVerticle.class, new JsonObject().put("instances", 1))); requiredVerticles.add(fromClass(vertx, LoggerManagerVerticle.class)); - - List>> optionalVerticles = new ArrayList<>(); if (Optional.ofNullable(config.getHealthConfig()).map(HealthConfig::isEnabled).orElse(true)) { requiredVerticles.add(fromClass(vertx, HealthCheckVerticle.class)); } + + List>> optionalVerticles = new ArrayList<>(); optionalVerticles.add(deployableWatchVerticle(options.getModelsDirectory(), ModelRefreshVerticle::new)); optionalVerticles.add(deployableWatchVerticle(options.getModulesDirectory(), DeployerVerticle::new)); optionalVerticles.add(deployableRedeployEntitiesJobVerticle(options)); LOGGER.info("Deploying system verticles ..."); - return all(List.of(fromDeployables(requiredVerticles).compose(allTo(this)), - all(optionalVerticles).map(CompositeFuture::list).map(optionals -> { - return optionals.stream().map(Optional.class::cast).filter(Optional::isPresent).map(Optional::get) - .map(Deployable.class::cast).toList(); - }).map(Deployables::new).compose(anyTo(this)))).mapEmpty(); + return all(fromDeployables(requiredVerticles).compose(allTo(this)).onFailure(throwable -> { + LOGGER.error("Failed to deploy (some / all) required system verticle(s)", throwable); + }), all(optionalVerticles).map(CompositeFuture::list).map(optionals -> { + return optionals.stream().map(Optional.class::cast).filter(Optional::isPresent).map(Optional::get) + .map(Deployable.class::cast).toList(); + }).map(Deployables::new).compose(anyTo(this)).onFailure(throwable -> { + LOGGER.error("Failed to deploy (some / all) optional system verticle(s), bootstrap will continue", + throwable); + }).otherwiseEmpty()).mapEmpty(); } private Future> deployableWatchVerticle( @@ -621,7 +629,9 @@ private Future> deployableRedeployEntitiesJobVert private Future deployServerVerticle() { LOGGER.info("Deploying server verticle ..."); return fromClass(vertx, ServerVerticle.class, new JsonObject().put("instances", NUMBER_DEFAULT_INSTANCES)) - .compose(deployable -> deployable.deploy(this)).mapEmpty(); + .compose(deployable -> deployable.deploy(this)).onFailure(throwable -> { + LOGGER.error("Failed to deploy server verticle", throwable); + }).mapEmpty(); } /** @@ -638,11 +648,7 @@ private Future deployClassPathVerticles() { return scanForDeployableClasses(vertx).compose(deployableClasses -> fromDeployables(deployableClasses.stream() .filter(verticleClass -> filterByAutoDeployAndProfiles(verticleClass, options.getActiveProfiles())) .map(verticleClass -> fromClass(vertx, verticleClass)).collect(Collectors.toList()))) - .onSuccess(deployables -> { - if (LOGGER.isInfoEnabled()) { - LOGGER.info("Deploy class path verticle(s) {}.", deployables.getIdentifier()); - } - }).compose(allTo(this)).mapEmpty(); + .compose(handleBootDeployment("class path verticle(s)")); } @VisibleForTesting @@ -665,7 +671,39 @@ private Future deployModules() { LOGGER.info("Deploying module(s) ..."); return fromDeployables(moduleJarPaths.stream().map(moduleJarPath -> fromJar(vertx, moduleJarPath)) - .collect(Collectors.toList())).compose(allTo(this)).mapEmpty(); + .collect(Collectors.toList())).compose(handleBootDeployment("module(s)")); + } + + private Function> handleBootDeployment(String deploymentType) { + BootDeploymentHandling handling = config.getBootDeploymentHandling(); + return deployables -> { + // in case we should keep partial deployments, for every deployable that we are about to deploy + // set the keep partial deployment flag, so that in case there is an error we don't undeploy + if (handling == KEEP_PARTIAL) { + for (Deployable deployable : deployables.getDeployables()) { + if (deployable instanceof Deployables) { + ((Deployables) deployable).keepPartialDeployment(); + } + } + } + + return (handling == FAIL_ON_ERROR ? allTo(this) : anyTo(this)).apply(deployables) + .onSuccess(deployments -> { + if (LOGGER.isInfoEnabled()) { + LOGGER.info("Successfully deployed all {} {}", + deploymentType, deployments.getDeploymentId()); + } + }).recover(throwable -> { + if (LOGGER.isErrorEnabled()) { + LOGGER.error("Failed to deploy (some / all) {}{}", + deploymentType, handling == FAIL_ON_ERROR ? "" : ", bootstrap will continue", + throwable); + } + + // abort the boot process if any class path verticle failed to deploy + return handling == FAIL_ON_ERROR ? failedFuture(throwable) : succeededFuture(); + }).mapEmpty(); + }; } @VisibleForTesting @@ -721,7 +759,7 @@ private void registerCloseHandler(Vertx vertx) { try { // unfortunately the addCloseHook method is public, but hidden in VertxImpl. As we need to know when the // instance shuts down, register a close hook using reflections (might fail due to a SecurityManager) - vertx.getClass().getMethod("addCloseHook", Closeable.class).invoke(vertx, (Closeable) completion -> { + VertxInternal.class.getMethod("addCloseHook", Closeable.class).invoke(vertx, (Closeable) completion -> { /* * Called when Vert.x instance is closed, perform shut-down operations here */ diff --git a/src/main/java/io/neonbee/config/NeonBeeConfig.java b/src/main/java/io/neonbee/config/NeonBeeConfig.java index 190122446..e6b785980 100644 --- a/src/main/java/io/neonbee/config/NeonBeeConfig.java +++ b/src/main/java/io/neonbee/config/NeonBeeConfig.java @@ -1,5 +1,6 @@ package io.neonbee.config; +import static io.neonbee.config.NeonBeeConfig.BootDeploymentHandling.FAIL_ON_ERROR; import static io.neonbee.internal.helper.ConfigHelper.notFound; import static io.neonbee.internal.helper.ConfigHelper.readConfig; import static io.neonbee.internal.helper.ConfigHelper.rephraseConfigNames; @@ -44,6 +45,31 @@ @DataObject @JsonGen(publicConverter = false) public class NeonBeeConfig { + /** + * How (non-system related) deployments like verticles and modules are handled during booting up {@link NeonBee}. + */ + public enum BootDeploymentHandling { + /** + * Abort the {@link NeonBee} boot, if any (non-system related) verticle / module deployment fails to be + * deployed. + */ + FAIL_ON_ERROR, + + /** + * Log any failure of a (non-system related) verticle / module deployments, but continue booting up + * {@link NeonBee} otherwise, while discarding / undeploying any partial deployments (i.e. if a module fails to + * deploy, all deployables related to that module will be undeployed again). + */ + UNDEPLOY_FAILING, + + /** + * Log any failure of a (non-system related) verticle / module deployments, but continue booting up + * {@link NeonBee} otherwise, while keeping partial deployments (i.e. if a module fails to deploy, some + * verticles might still stay deployed). + */ + KEEP_PARTIAL + } + /** * The default timeout for an event bus request. */ @@ -74,6 +100,8 @@ public class NeonBeeConfig { private int eventBusTimeout = DEFAULT_EVENT_BUS_TIMEOUT; + private BootDeploymentHandling bootDeploymentHandling = FAIL_ON_ERROR; + private int deploymentTimeout = DEFAULT_DEPLOYMENT_TIMEOUT; private Integer modelsDeploymentTimeout; @@ -258,6 +286,26 @@ public NeonBeeConfig setEventBusTimeout(int eventBusTimeout) { return this; } + /** + * Gets how {@link NeonBee} handles failures deploying verticles / modules during boot. + * + * @return the selected boot deployment handling method + */ + public BootDeploymentHandling getBootDeploymentHandling() { + return bootDeploymentHandling; + } + + /** + * Sets how {@link NeonBee} should handle failures deploying verticles / modules during boot. + * + * @param bootDeploymentHandling the selected boot deployment handling method + * @return the {@linkplain NeonBeeConfig} for fluent use + */ + public NeonBeeConfig setBootDeploymentHandling(BootDeploymentHandling bootDeploymentHandling) { + this.bootDeploymentHandling = bootDeploymentHandling; + return this; + } + /** * Returns the general deployment timeout for an individual deployment of any type in seconds. If unset / equal or * smaller than 0, no timeout applies to the deployment. diff --git a/src/main/java/io/neonbee/internal/deploy/Deployables.java b/src/main/java/io/neonbee/internal/deploy/Deployables.java index d0354c38a..65da8c6f8 100644 --- a/src/main/java/io/neonbee/internal/deploy/Deployables.java +++ b/src/main/java/io/neonbee/internal/deploy/Deployables.java @@ -167,10 +167,9 @@ protected Future undeploy(String deploymentId) { getDeployables().stream().map(deployable -> deployable.deploy(neonBee)).forEach(pendingDeployments::add); // when we should keep partial deployments use a joinComposite, so we wait for all deployments to finish - // independent if a single one fails or not. in case we should not keep partial deployments (default) use - // allComposite here, which will fail, when one deployment fails, and thus we can start undeploying all - // succeeded - // (or to be succeeded pending deployments) as unfortunately there is no way to cancel active deployments + // independent if a single one fails. in case we should not keep partial deployments (default) use allComposite + // here, which will fail, when one deployment fails, and thus we can start undeploying all succeeded (or to be + // succeeded pending deployments) as unfortunately there is no way to cancel active deployments (keepPartialDeployment ? Future.join(pendingDeployments) : Future.all(pendingDeployments)) .onComplete(deployPromise); diff --git a/src/test/java/io/neonbee/NeonBeeTest.java b/src/test/java/io/neonbee/NeonBeeTest.java index 8eb002782..317db4c30 100644 --- a/src/test/java/io/neonbee/NeonBeeTest.java +++ b/src/test/java/io/neonbee/NeonBeeTest.java @@ -12,14 +12,16 @@ import static io.neonbee.NeonBeeProfile.STABLE; import static io.neonbee.internal.helper.StringHelper.EMPTY; import static io.neonbee.test.base.NeonBeeTestBase.LONG_RUNNING_TEST; -import static io.neonbee.test.helper.DeploymentHelper.getDeployedVerticles; +import static io.neonbee.test.helper.DeploymentHelper.getAllDeployedVerticleClasses; import static io.neonbee.test.helper.OptionsHelper.defaultOptions; import static io.neonbee.test.helper.ResourceHelper.TEST_RESOURCES; import static io.vertx.core.Future.failedFuture; import static io.vertx.core.Future.succeededFuture; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -33,11 +35,14 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiFunction; import java.util.regex.Pattern; import java.util.stream.Stream; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; @@ -53,6 +58,7 @@ import io.neonbee.NeonBeeInstanceConfiguration.ClusterManager; import io.neonbee.config.NeonBeeConfig; +import io.neonbee.config.NeonBeeConfig.BootDeploymentHandling; import io.neonbee.health.DummyHealthCheck; import io.neonbee.health.DummyHealthCheckProvider; import io.neonbee.health.EventLoopHealthCheck; @@ -76,6 +82,8 @@ import io.vertx.core.AsyncResult; import io.vertx.core.Future; import io.vertx.core.Handler; +import io.vertx.core.Promise; +import io.vertx.core.Verticle; import io.vertx.core.Vertx; import io.vertx.core.eventbus.DeliveryContext; import io.vertx.core.eventbus.EventBus; @@ -91,6 +99,8 @@ class NeonBeeTest extends NeonBeeTestBase { private Vertx vertx; + private static boolean incubatorFails; + @Override protected void adaptOptions(TestInfo testInfo, NeonBeeOptions.Mutable options) { options.addActiveProfile(NO_WEB); @@ -111,6 +121,11 @@ protected void adaptOptions(TestInfo testInfo, NeonBeeOptions.Mutable options) { } } + @BeforeEach + void reset() { + incubatorFails = false; + } + @AfterEach void closeVertx(VertxTestContext testContext) { if (vertx != null) { @@ -151,27 +166,28 @@ void testStart(Vertx vertx) { @Test @DisplayName("NeonBee should deploy all none optional system verticles") void testDeployNoneOptionalSystemVerticles(Vertx vertx) { - assertThat(getDeployedVerticles(vertx)).containsExactly(ConsolidationVerticle.class, + assertThat(getAllDeployedVerticleClasses(vertx)).containsExactly(ConsolidationVerticle.class, LoggerManagerVerticle.class); } @Test @DisplayName("NeonBee should deploy all none optional system verticles plus HealthCheckVerticle") void testDeployNoneOptionalSystemVerticlesPlusHealthCheckVerticle(Vertx vertx) { - assertThat(getDeployedVerticles(vertx)).containsExactly(ConsolidationVerticle.class, + assertThat(getAllDeployedVerticleClasses(vertx)).containsExactly(ConsolidationVerticle.class, LoggerManagerVerticle.class, HealthCheckVerticle.class); } @Test @DisplayName("NeonBee should deploy class path verticles (from NeonBeeExtensionBasedTest)") void testDeployCoreVerticlesFromClassPath(Vertx vertx) { - assertThat(getDeployedVerticles(vertx)).contains(NeonBeeExtensionBasedTest.CoreDataVerticle.class); + assertThat(getAllDeployedVerticleClasses(vertx)).contains(NeonBeeExtensionBasedTest.CoreDataVerticle.class); } @Test @DisplayName("NeonBee should deploy module JAR") void testDeployModule(Vertx vertx) { - assertThat(getDeployedVerticles(vertx).stream().map(Class::getName)).containsAtLeast("ClassA", "ClassB"); + assertThat(getAllDeployedVerticleClasses(vertx).stream().map(Class::getName)).containsAtLeast("ClassA", + "ClassB"); } @Test @@ -459,6 +475,64 @@ void testEncryptionOptions(String scenario, NeonBeeOptions neonBeeOptions, NeonBee.applyEncryptionOptions(neonBeeOptions, ebo).onComplete(verifier.apply(ebo, testContext)); } + @Test + @DisplayName("Test if NeonBee boot fails if deployable fails, when FAIL_ON_ERROR is set") + @Tag(DOESNT_REQUIRE_NEONBEE) + void testDeployableFailure(VertxTestContext testContext) { + AtomicReference vertxSpy = new AtomicReference<>(); + incubatorFails = true; + NeonBeeOptions options = defaultOptions().clearActiveProfiles().addActiveProfile(INCUBATOR) + .setIgnoreClassPath(false); + NeonBeeConfig config = new NeonBeeConfig().setBootDeploymentHandling(BootDeploymentHandling.FAIL_ON_ERROR); + NeonBee.create((NeonBee.OwnVertxFactory) (vertxOptions, clusterManager) -> NeonBee + .newVertx(vertxOptions, clusterManager, options) + .map(newVertx -> registerVertxCloseSpy(vertx = newVertx, vertxSpy)), + ClusterManager.FAKE.factory(), options, config) + .onComplete(testContext.failing(throwable -> testContext.verify(() -> { + assertThat(throwable).hasMessageThat().isEqualTo("incubator verticle fails"); + verify(vertxSpy.get()).close(); // verify that the spy received a call to close() + testContext.completeNow(); + }))); + } + + @Test + @DisplayName("Test if NeonBee boot doesn't fail if deployable fails, when UNDEPLOY_FAILING is set") + @Tag(DOESNT_REQUIRE_NEONBEE) + void testUndeployFailing(VertxTestContext testContext) { + AtomicReference vertxSpy = new AtomicReference<>(); + incubatorFails = true; + NeonBeeOptions options = defaultOptions().clearActiveProfiles().addActiveProfile(INCUBATOR) + .setIgnoreClassPath(false); + NeonBeeConfig config = new NeonBeeConfig().setBootDeploymentHandling(BootDeploymentHandling.UNDEPLOY_FAILING); + NeonBee.create((NeonBee.OwnVertxFactory) (vertxOptions, clusterManager) -> NeonBee + .newVertx(vertxOptions, clusterManager, options) + .map(newVertx -> registerVertxCloseSpy(vertx = newVertx, vertxSpy)), + ClusterManager.FAKE.factory(), options, config) + .onComplete(testContext.succeeding(neonBee -> testContext.verify(() -> { + verify(vertxSpy.get(), times(0)).close(); // verify that the spy wasn't closed! + Set> verticleClasses = getAllDeployedVerticleClasses(vertx); + // verify that both the IncubatorVerticle was not deployed, while the probe was deployed + assertThat(verticleClasses).doesNotContain(IncubatorVerticle.class); + assertThat(verticleClasses).contains(IncubatorProbeVerticle.class); + testContext.completeNow(); + }))); + } + + private static Vertx registerVertxCloseSpy(Vertx newVertx, AtomicReference vertxSpy) { + // we have to spy on the close method, because the test runner closes Vert.x not NeonBee (if we + // wouldn't do that, the failing startup would cause the event loop threads to close and cause an + // early failure of the test because the "event executor terminated" (RejectedExecutionException) + Vertx newVertxSpy = spy(newVertx); + // also register the original newVertx with a NeonBee mock, otherwise some internal calls will fail + NeonBeeMockHelper.registerNeonBeeMock(newVertx); + // to stub spies Mockito suggests it is safer to use the "doReturn" method instead of "when", also + // we cannot simply return a succeeded future here, because the future returned by Vertx.close() is + // "special" because it is cast into a "ContextInternal", thus we use a "real" Vertx.close() future + doReturn(Vertx.vertx().close()).when(newVertxSpy).close(); + vertxSpy.set(newVertxSpy); + return newVertxSpy; + } + @NeonBeeDeployable(profile = CORE) private static class CoreVerticle extends AbstractVerticle { // empty class (comment needed as spotless formatter works different on windows) @@ -470,7 +544,19 @@ private static class StableVerticle extends AbstractVerticle { } @NeonBeeDeployable(profile = INCUBATOR) - private static class IncubatorVerticle extends AbstractVerticle { + public static class IncubatorVerticle extends AbstractVerticle { + @Override + public void start(Promise startPromise) throws Exception { + if (incubatorFails) { + startPromise.fail("incubator verticle fails"); + } else { + startPromise.complete(); + } + } + } + + @NeonBeeDeployable(profile = INCUBATOR) + public static class IncubatorProbeVerticle extends AbstractVerticle { // empty class (comment needed as spotless formatter works different on windows) } diff --git a/src/test/java/io/neonbee/test/helper/DeploymentHelper.java b/src/test/java/io/neonbee/test/helper/DeploymentHelper.java index 7d7e657c2..0e4e6842f 100644 --- a/src/test/java/io/neonbee/test/helper/DeploymentHelper.java +++ b/src/test/java/io/neonbee/test/helper/DeploymentHelper.java @@ -63,14 +63,14 @@ public static Future undeployVerticle(Vertx vertx, String deploymentID) { */ public static Future undeployAllVerticlesOfClass(Vertx vertx, Class verticleClass) { return Future - .all(getAllDeployments(vertx) + .all(streamAllDeployments(vertx) .filter(deployment -> deployment.getVerticles().stream().anyMatch(verticleClass::isInstance)) .map(deployment -> undeployVerticle(vertx, deployment.deploymentID())).collect(toList())) .mapEmpty(); } public static boolean isVerticleDeployed(Vertx vertx, Class verticleToCheck) { - return getAllDeployedVerticles(vertx).anyMatch(verticleToCheck::isInstance); + return streamAllDeployedVerticles(vertx).anyMatch(verticleToCheck::isInstance); } /** @@ -78,17 +78,39 @@ public static boolean isVerticleDeployed(Vertx vertx, Class * * @param vertx The related Vert.x instance * @return A Set of the classes of the deployed Verticles. + * @deprecated Use {@link #getAllDeployedVerticleClasses(Vertx)} instead */ + @Deprecated(forRemoval = true) public static Set> getDeployedVerticles(Vertx vertx) { - return getAllDeployedVerticles(vertx).map(Verticle::getClass).collect(Collectors.toSet()); + return getAllDeployedVerticleClasses(vertx); } - private static Stream getAllDeployments(Vertx vertx) { + /** + * Provides a Set of the deployed Verticles. + * + * @param vertx The related Vert.x instance + * @return A Set of the deployed Verticles. + */ + public static Set getAllDeployedVerticles(Vertx vertx) { + return streamAllDeployedVerticles(vertx).collect(Collectors.toSet()); + } + + /** + * Provides a Set of the classes of the deployed Verticles. + * + * @param vertx The related Vert.x instance + * @return A Set of the classes of the deployed Verticles. + */ + public static Set> getAllDeployedVerticleClasses(Vertx vertx) { + return streamAllDeployedVerticles(vertx).map(Verticle::getClass).collect(Collectors.toSet()); + } + + private static Stream streamAllDeployments(Vertx vertx) { return vertx.deploymentIDs().stream().map(((VertxInternal) vertx)::getDeployment); } - private static Stream getAllDeployedVerticles(Vertx vertx) { - return getAllDeployments(vertx).map(Deployment::getVerticles).flatMap(Set::stream); + private static Stream streamAllDeployedVerticles(Vertx vertx) { + return streamAllDeployments(vertx).map(Deployment::getVerticles).flatMap(Set::stream); } private DeploymentHelper() {