diff --git a/README.md b/README.md index ea21de395e..e0ff73b2d4 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ Cryostat can be configured via the following environment variables: * `CRYOSTAT_CORS_ORIGIN`: the origin for CORS to load a different cryostat-web instance. Defaults to the empty string, which disables CORS. * `CRYOSTAT_MAX_WS_CONNECTIONS`: the maximum number of websocket client connections allowed (minimum 1, maximum `Integer.MAX_VALUE`, default `Integer.MAX_VALUE`) * `CRYOSTAT_AUTH_MANAGER`: the authentication/authorization manager used for validating user accesses. See the `USER AUTHENTICATION / AUTHORIZATION` section for more details. Set to the fully-qualified class name of the auth manager implementation to use, ex. `io.cryostat.net.BasicAuthManager`. Defaults to an AuthManager corresponding to the selected deployment platform, whether explicit or automatic (see below). -* `CRYOSTAT_PLATFORM`: the platform client used for performing platform-specific actions, such as listing available target JVMs. If `CRYOSTAT_AUTH_MANAGER` is not specified then a default auth manager will also be selected corresponding to the platform, whether that platform is specified by the user or automatically detected. Set to the fully-qualified name of the platform detection strategy implementation to use, ex. `io.cryostat.platform.internal.KubeEnvPlatformStrategy`. +* `CRYOSTAT_PLATFORM`: the platform clients used for performing platform-specific actions, such as listing available target JVMs. If `CRYOSTAT_AUTH_MANAGER` is not specified then a default auth manager will also be selected corresponding to the highest priority platform, whether those platforms are specified by the user or automatically detected. Set to the fully-qualified names of the platform detection strategy implementations to use, ex. `io.cryostat.platform.internal.KubeEnvPlatformStrategy,io.cryostat.platform.internal.PodmanPlatformStrategy`. * `CRYOSTAT_ENABLE_JDP_BROADCAST`: enable the Cryostat JVM to broadcast itself via JDP (Java Discovery Protocol). Defaults to `true`. * `CRYOSTAT_JDP_ADDRESS`: the JDP multicast address to send discovery packets. Defaults to `224.0.23.178`. * `CRYOSTAT_JDP_PORT`: the JDP multicast port to send discovery packets. Defaults to `7095`. diff --git a/pom.xml b/pom.xml index 087dbce64f..636432c4da 100644 --- a/pom.xml +++ b/pom.xml @@ -207,6 +207,14 @@ jasypt-hibernate5 ${org.jasypt-hibernate5.version} + + io.netty + netty-transport-native-epoll + linux-x86_64 + 4.1.86.Final + + + org.slf4j slf4j-jdk14 diff --git a/run.sh b/run.sh index b820b14229..d3fce69f7d 100755 --- a/run.sh +++ b/run.sh @@ -107,6 +107,14 @@ if ! podman pod exists cryostat-pod; then --publish "$CRYOSTAT_EXT_WEB_PORT":"$CRYOSTAT_WEB_PORT" fi +# do: $ podman system service -t 0 +# or do: $ systemctl --user start podman.socket +# to create the podman.sock to volume-mount into the container +# +# to check the podman socket is reachable and connectable within the container: +# $ podman exec -it cryo /bin/sh +# sh-4.4# curl -v -s --unix-socket /run/user/0/podman/podman.sock http://d:80/v3.0.0/libpod/info +# # run as root (uid 0) within the container - with rootless podman this means # that the process will actually run with your own uid on the host machine, # rather than the uid being remapped to something else @@ -114,6 +122,7 @@ podman run \ --pod cryostat-pod \ --name cryostat \ --user 0 \ + --label io.cryostat.connectUrl="service:jmx:rmi:///jndi/rmi://localhost:0/jmxrmi" \ --memory 768M \ --mount type=bind,source="$(dirname "$0")/archive",destination=/opt/cryostat.d/recordings.d,relabel=shared \ --mount type=bind,source="$(dirname "$0")/certs",destination=/certs,relabel=shared \ @@ -122,6 +131,8 @@ podman run \ --mount type=bind,source="$(dirname "$0")/templates",destination=/opt/cryostat.d/templates.d,relabel=shared \ --mount type=bind,source="$(dirname "$0")/truststore",destination=/truststore,relabel=shared \ --mount type=bind,source="$(dirname "$0")/probes",destination=/opt/cryostat.d/conf.d/probes.d,relabel=shared \ + -v "$XDG_RUNTIME_DIR"/podman/podman.sock:/run/user/0/podman/podman.sock:Z \ + --security-opt label=disable \ -e CRYOSTAT_ENABLE_JDP_BROADCAST="true" \ -e CRYOSTAT_REPORT_GENERATOR="$CRYOSTAT_REPORT_GENERATOR" \ -e CRYOSTAT_PLATFORM="$CRYOSTAT_PLATFORM" \ diff --git a/smoketest.sh b/smoketest.sh index 2f59c532fb..4aebf72254 100755 --- a/smoketest.sh +++ b/smoketest.sh @@ -97,6 +97,7 @@ runDemoApps() { --env HTTP_PORT=8081 \ --env JMX_PORT=9093 \ --pod cryostat-pod \ + --label io.cryostat.connectUrl="service:jmx:rmi:///jndi/rmi://localhost:9093/jmxrmi" \ --rm -d quay.io/andrewazores/vertx-fib-demo:0.9.1 podman run \ @@ -105,6 +106,7 @@ runDemoApps() { --env JMX_PORT=9094 \ --env USE_AUTH=true \ --pod cryostat-pod \ + --label io.cryostat.connectUrl="service:jmx:rmi:///jndi/rmi://localhost:9094/jmxrmi" \ --rm -d quay.io/andrewazores/vertx-fib-demo:0.9.1 podman run \ @@ -114,6 +116,7 @@ runDemoApps() { --env USE_SSL=true \ --env USE_AUTH=true \ --pod cryostat-pod \ + --label io.cryostat.connectUrl="service:jmx:rmi:///jndi/rmi://localhost:9095/jmxrmi" \ --rm -d quay.io/andrewazores/vertx-fib-demo:0.9.1 local webPort; @@ -234,6 +237,7 @@ runReportGenerator() { --name reports \ --pull always \ --pod cryostat-pod \ + --label io.cryostat.connectUrl="service:jmx:remote+http://localhost:${RJMX_PORT}" \ --cpus 1 \ --memory 512M \ --restart on-failure \ diff --git a/src/main/java/io/cryostat/discovery/BuiltInDiscovery.java b/src/main/java/io/cryostat/discovery/BuiltInDiscovery.java index 3950574899..628b297b73 100644 --- a/src/main/java/io/cryostat/discovery/BuiltInDiscovery.java +++ b/src/main/java/io/cryostat/discovery/BuiltInDiscovery.java @@ -37,19 +37,22 @@ */ package io.cryostat.discovery; +import java.util.HashSet; +import java.util.Iterator; import java.util.Map; import java.util.Set; +import java.util.SortedSet; import java.util.UUID; import java.util.function.Consumer; +import java.util.stream.Stream; -import io.cryostat.configuration.Variables; import io.cryostat.core.log.Logger; -import io.cryostat.core.sys.Environment; import io.cryostat.messaging.notifications.NotificationFactory; import io.cryostat.platform.PlatformClient; import io.cryostat.platform.TargetDiscoveryEvent; import io.cryostat.platform.discovery.EnvironmentNode; import io.cryostat.platform.internal.CustomTargetPlatformClient; +import io.cryostat.platform.internal.PlatformDetectionStrategy; import dagger.Lazy; import io.vertx.core.AbstractVerticle; @@ -60,23 +63,24 @@ public class BuiltInDiscovery extends AbstractVerticle implements Consumer platformClients; + private final Set> selectedStrategies; + private final Set> unselectedStrategies; private final Lazy customTargets; - private final Environment env; + private final Set enabledClients = new HashSet<>(); private final NotificationFactory notificationFactory; private final Logger logger; BuiltInDiscovery( DiscoveryStorage storage, - Set platformClients, + SortedSet> selectedStrategies, + SortedSet> unselectedStrategies, Lazy customTargets, - Environment env, NotificationFactory notificationFactory, Logger logger) { this.storage = storage; - this.platformClients = platformClients; + this.selectedStrategies = selectedStrategies; + this.unselectedStrategies = unselectedStrategies; this.customTargets = customTargets; - this.env = env; this.notificationFactory = notificationFactory; this.logger = logger; } @@ -84,9 +88,23 @@ public class BuiltInDiscovery extends AbstractVerticle implements Consumer start) { storage.addTargetDiscoveryListener(this); - (env.hasEnv(Variables.DISABLE_BUILTIN_DISCOVERY) - ? Set.of(customTargets.get()) - : platformClients) + + unselectedStrategies.stream() + .map(PlatformDetectionStrategy::getPlatformClient) + .forEach( + platform -> + storage.getBuiltInPluginByRealm( + platform.getDiscoveryTree().getName()) + .map(PluginInfo::getId) + .ifPresent(storage::deregister)); + + Stream.concat( + // ensure custom targets is always available regardless of other + // configurations + Stream.of(customTargets.get()), + selectedStrategies.stream() + .map(PlatformDetectionStrategy::getPlatformClient)) + .distinct() .forEach( platform -> { logger.info( @@ -126,6 +144,7 @@ public void start(Promise start) { try { platform.start(); platform.load(promise); + enabledClients.add(platform); } catch (Exception e) { start.fail(e); } @@ -136,14 +155,15 @@ public void start(Promise start) { @Override public void stop() { storage.removeTargetDiscoveryListener(this); - this.platformClients.forEach( - platform -> { - try { - platform.stop(); - } catch (Exception e) { - logger.error(e); - } - }); + Iterator it = enabledClients.iterator(); + while (it.hasNext()) { + try { + it.next().stop(); + } catch (Exception e) { + logger.error(e); + } + it.remove(); + } } @Override diff --git a/src/main/java/io/cryostat/discovery/DiscoveryModule.java b/src/main/java/io/cryostat/discovery/DiscoveryModule.java index f27560dd5f..8947ec6edc 100644 --- a/src/main/java/io/cryostat/discovery/DiscoveryModule.java +++ b/src/main/java/io/cryostat/discovery/DiscoveryModule.java @@ -39,6 +39,7 @@ import java.time.Duration; import java.util.Set; +import java.util.SortedSet; import javax.inject.Named; import javax.inject.Singleton; @@ -50,9 +51,10 @@ import io.cryostat.core.log.Logger; import io.cryostat.core.sys.Environment; import io.cryostat.messaging.notifications.NotificationFactory; -import io.cryostat.platform.PlatformClient; +import io.cryostat.platform.PlatformModule; import io.cryostat.platform.discovery.AbstractNode; import io.cryostat.platform.internal.CustomTargetPlatformClient; +import io.cryostat.platform.internal.PlatformDetectionStrategy; import io.cryostat.recordings.JvmIdHelper; import io.cryostat.rules.MatchExpressionEvaluator; import io.cryostat.util.PluggableTypeAdapter; @@ -116,13 +118,20 @@ static DiscoveryStorage provideDiscoveryStorage( @Singleton static BuiltInDiscovery provideBuiltInDiscovery( DiscoveryStorage storage, - Set platformClients, + @Named(PlatformModule.SELECTED_PLATFORMS) + SortedSet> selectedStrategies, + @Named(PlatformModule.UNSELECTED_PLATFORMS) + SortedSet> unselectedStrategies, Lazy customTargets, - Environment env, NotificationFactory notificationFactory, Logger logger) { return new BuiltInDiscovery( - storage, platformClients, customTargets, env, notificationFactory, logger); + storage, + selectedStrategies, + unselectedStrategies, + customTargets, + notificationFactory, + logger); } @Provides diff --git a/src/main/java/io/cryostat/net/HttpServer.java b/src/main/java/io/cryostat/net/HttpServer.java index 0d7a9601e6..8137a47755 100644 --- a/src/main/java/io/cryostat/net/HttpServer.java +++ b/src/main/java/io/cryostat/net/HttpServer.java @@ -82,7 +82,10 @@ public class HttpServer extends AbstractVerticle { .setPort(netConf.getInternalWebServerPort()) .addWebSocketSubProtocol("*") .setCompressionSupported(true) - .setLogActivity(true))); + .setLogActivity(true) + .setTcpFastOpen(true) + .setTcpNoDelay(true) + .setTcpQuickAck(true))); if (!sslConf.enabled()) { this.logger.warn("No available SSL certificates. Fallback to plain HTTP."); diff --git a/src/main/java/io/cryostat/net/NetworkModule.java b/src/main/java/io/cryostat/net/NetworkModule.java index 8eee644a8c..524f9ca1cc 100644 --- a/src/main/java/io/cryostat/net/NetworkModule.java +++ b/src/main/java/io/cryostat/net/NetworkModule.java @@ -69,6 +69,7 @@ import dagger.Provides; import dagger.multibindings.IntoSet; import io.vertx.core.Vertx; +import io.vertx.core.VertxOptions; import io.vertx.ext.web.client.WebClient; import io.vertx.ext.web.client.WebClientOptions; @@ -155,7 +156,7 @@ static JFRConnectionToolkit provideJFRConnectionToolkit( @Provides @Singleton static Vertx provideVertx() { - return Vertx.vertx(); + return Vertx.vertx(new VertxOptions().setPreferNativeTransport(true)); } @Provides diff --git a/src/main/java/io/cryostat/platform/PlatformModule.java b/src/main/java/io/cryostat/platform/PlatformModule.java index 7e1dfb755f..841300a0d7 100644 --- a/src/main/java/io/cryostat/platform/PlatformModule.java +++ b/src/main/java/io/cryostat/platform/PlatformModule.java @@ -37,16 +37,21 @@ */ package io.cryostat.platform; +import java.util.Arrays; +import java.util.List; +import java.util.NoSuchElementException; import java.util.Objects; import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.function.Predicate; +import javax.inject.Named; import javax.inject.Singleton; import io.cryostat.configuration.Variables; import io.cryostat.core.log.Logger; -import io.cryostat.core.net.discovery.JvmDiscoveryClient; import io.cryostat.core.sys.Environment; -import io.cryostat.core.sys.FileSystem; import io.cryostat.discovery.DiscoveryStorage; import io.cryostat.net.AuthManager; import io.cryostat.platform.discovery.PlatformDiscoveryModule; @@ -54,15 +59,16 @@ import io.cryostat.platform.internal.PlatformDetectionStrategy; import io.cryostat.platform.internal.PlatformStrategyModule; -import dagger.Binds; import dagger.Lazy; import dagger.Module; import dagger.Provides; -import dagger.multibindings.IntoSet; @Module(includes = {PlatformStrategyModule.class, PlatformDiscoveryModule.class}) public abstract class PlatformModule { + public static final String SELECTED_PLATFORMS = "SELECTED_PLATFORMS"; + public static final String UNSELECTED_PLATFORMS = "UNSELECTED_PLATFORMS"; + @Provides @Singleton static CustomTargetPlatformClient provideCustomTargetPlatformClient( @@ -70,78 +76,97 @@ static CustomTargetPlatformClient provideCustomTargetPlatformClient( return new CustomTargetPlatformClient(storage); } - @Binds - @IntoSet - abstract PlatformClient bindCustomTargetPlatformClient(CustomTargetPlatformClient client); - @Provides @Singleton static AuthManager provideAuthManager( PlatformDetectionStrategy platformStrategy, Environment env, - FileSystem fs, Set authManagers, Logger logger) { final String authManagerClass; if (env.hasEnv(Variables.AUTH_MANAGER_ENV_VAR)) { authManagerClass = env.getEnv(Variables.AUTH_MANAGER_ENV_VAR); logger.info("Selecting configured AuthManager \"{}\"", authManagerClass); + return authManagers.stream() + .filter( + mgr -> + Objects.equals( + mgr.getClass().getCanonicalName(), authManagerClass)) + .findFirst() + .orElseThrow( + () -> + new RuntimeException( + String.format( + "Selected AuthManager \"%s\" is not available", + authManagerClass))); } else { - authManagerClass = platformStrategy.getAuthManager().getClass().getCanonicalName(); - logger.info("Selecting platform default AuthManager \"{}\"", authManagerClass); + AuthManager auth = platformStrategy.getAuthManager(); + logger.info( + "Selecting platform default AuthManager \"{}\"", + auth.getClass().getCanonicalName()); + return auth; } - return authManagers.stream() - .filter(mgr -> Objects.equals(mgr.getClass().getCanonicalName(), authManagerClass)) - .findFirst() - .orElseThrow( - () -> - new RuntimeException( - String.format( - "Selected AuthManager \"%s\" is not available", - authManagerClass))); } @Provides @Singleton - static PlatformDetectionStrategy providePlatformStrategy( - Logger logger, Set> strategies, Environment env) { - PlatformDetectionStrategy strat = null; + @Named(SELECTED_PLATFORMS) + static SortedSet> provideSelectedPlatformStrategies( + Set> platformStrategies, Environment env) { + // reverse sort, higher priorities should be earlier in the stream + SortedSet> selectedStrategies = + new TreeSet<>((a, b) -> Integer.compare(b.getPriority(), a.getPriority())); + Predicate> fn; if (env.hasEnv(Variables.PLATFORM_STRATEGY_ENV_VAR)) { - String platform = env.getEnv(Variables.PLATFORM_STRATEGY_ENV_VAR); - logger.info("Selecting configured PlatformDetectionStrategy \"{}\"", platform); - for (PlatformDetectionStrategy s : strategies) { - if (Objects.equals(platform, s.getClass().getCanonicalName())) { - strat = s; - break; - } - } - if (strat == null) { - throw new RuntimeException( - String.format( - "Selected PlatformDetectionStrategy \"%s\" not found", platform)); - } + List platforms = + Arrays.asList(env.getEnv(Variables.PLATFORM_STRATEGY_ENV_VAR).split(",")); + fn = s -> platforms.contains(s.getClass().getCanonicalName()); + } else if (env.hasEnv(Variables.DISABLE_BUILTIN_DISCOVERY)) { + fn = s -> false; + } else { + fn = PlatformDetectionStrategy::isAvailable; } - if (strat == null) { - strat = - strategies.stream() - // reverse sort, higher priorities should be earlier in the stream - .sorted((a, b) -> Integer.compare(b.getPriority(), a.getPriority())) - .filter(PlatformDetectionStrategy::isAvailable) - .findFirst() - .orElseThrow(); + for (PlatformDetectionStrategy s : platformStrategies) { + if (fn.test(s)) { + selectedStrategies.add(s); + } } - return strat; + return selectedStrategies; } @Provides - @IntoSet - static PlatformClient provideDetectedPlatformClient(PlatformDetectionStrategy strat) { - return strat.getPlatformClient(); + @Singleton + @Named(UNSELECTED_PLATFORMS) + static SortedSet> provideUnselectedPlatformStrategies( + @Named(SELECTED_PLATFORMS) SortedSet> selectedStrategies, + Set> platformStrategies) { + SortedSet> unselected = + new TreeSet<>((a, b) -> Integer.compare(b.getPriority(), a.getPriority())); + unselected.addAll(platformStrategies); + unselected.removeAll(selectedStrategies); + return unselected; } @Provides @Singleton - static JvmDiscoveryClient provideJvmDiscoveryClient(Logger logger) { - return new JvmDiscoveryClient(logger); + static PlatformDetectionStrategy providePlatformStrategy( + @Named(SELECTED_PLATFORMS) SortedSet> selectedStrategies, + Set> strategies) { + return selectedStrategies.stream() + .findFirst() + .orElseThrow( + () -> + new NoSuchElementException( + String.format( + "No selected platforms found. Available platforms:" + + " %s", + strategies.stream() + .sorted( + (a, b) -> + Integer.compare( + b.getPriority(), + a.getPriority())) + .map(s -> s.getClass().getCanonicalName()) + .toList()))); } } diff --git a/src/main/java/io/cryostat/platform/internal/PlatformStrategyModule.java b/src/main/java/io/cryostat/platform/internal/PlatformStrategyModule.java index 9b3eeac50d..104e46f4d3 100644 --- a/src/main/java/io/cryostat/platform/internal/PlatformStrategyModule.java +++ b/src/main/java/io/cryostat/platform/internal/PlatformStrategyModule.java @@ -52,10 +52,12 @@ import io.cryostat.net.openshift.OpenShiftAuthManager; import io.cryostat.net.web.WebModule; +import com.google.gson.Gson; import dagger.Lazy; import dagger.Module; import dagger.Provides; import dagger.multibindings.ElementsIntoSet; +import io.vertx.core.Vertx; @Module public abstract class PlatformStrategyModule { @@ -68,16 +70,19 @@ static Set> providePlatformDetectionStrategies( Lazy noopAuthManager, Lazy connectionToolkit, @Named(WebModule.VERTX_EXECUTOR) ExecutorService executor, + Vertx vertx, + Gson gson, NetworkResolver resolver, Environment env, - FileSystem fs, - Lazy discoveryClient) { + FileSystem fs) { return Set.of( new OpenShiftPlatformStrategy( logger, executor, openShiftAuthManager, connectionToolkit, env, fs), new KubeApiPlatformStrategy( logger, executor, noopAuthManager, connectionToolkit, env, fs), new KubeEnvPlatformStrategy(logger, fs, noopAuthManager, connectionToolkit, env), - new DefaultPlatformStrategy(logger, noopAuthManager, discoveryClient)); + new PodmanPlatformStrategy(logger, noopAuthManager, vertx, gson, fs), + new DefaultPlatformStrategy( + logger, noopAuthManager, () -> new JvmDiscoveryClient(logger))); } } diff --git a/src/main/java/io/cryostat/platform/internal/PodmanPlatformClient.java b/src/main/java/io/cryostat/platform/internal/PodmanPlatformClient.java new file mode 100644 index 0000000000..be325ae42f --- /dev/null +++ b/src/main/java/io/cryostat/platform/internal/PodmanPlatformClient.java @@ -0,0 +1,300 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.platform.internal; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +import javax.management.remote.JMXServiceURL; + +import io.cryostat.core.log.Logger; +import io.cryostat.core.net.discovery.JvmDiscoveryClient.EventKind; +import io.cryostat.platform.AbstractPlatformClient; +import io.cryostat.platform.ServiceRef; +import io.cryostat.platform.ServiceRef.AnnotationKey; +import io.cryostat.platform.discovery.AbstractNode; +import io.cryostat.platform.discovery.BaseNodeType; +import io.cryostat.platform.discovery.EnvironmentNode; +import io.cryostat.platform.discovery.NodeType; +import io.cryostat.platform.discovery.TargetNode; +import io.cryostat.util.URIUtil; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.net.SocketAddress; +import io.vertx.ext.web.client.HttpResponse; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.codec.BodyCodec; +import org.apache.commons.lang3.StringUtils; + +public class PodmanPlatformClient extends AbstractPlatformClient { + + public static final String REALM = "Podman"; + public static final String CRYOSTAT_LABEL = "io.cryostat.connectUrl"; + + private final Vertx vertx; + private final WebClient webClient; + private final Gson gson; + private final SocketAddress podmanSocket; + private final Logger logger; + private long timerId; + + private final CopyOnWriteArrayList containers = new CopyOnWriteArrayList<>(); + + PodmanPlatformClient(Vertx vertx, SocketAddress podmanSocket, Gson gson, Logger logger) { + this.vertx = vertx; + this.webClient = WebClient.create(vertx); + this.podmanSocket = podmanSocket; + this.gson = gson; + this.logger = logger; + } + + @Override + public void start() throws Exception { + super.start(); + queryContainers(); + this.timerId = + vertx.setPeriodic( + // TODO make this configurable + 10_000, unused -> queryContainers()); + } + + @Override + public void stop() throws Exception { + super.stop(); + vertx.cancelTimer(timerId); + } + + @Override + public List listDiscoverableServices() { + HashMap result = new HashMap<>(); + + for (ContainerSpec container : containers) { + result.put(container.Id, container); + } + + return convert(result.values()); + } + + private void queryContainers() { + doPodmanRequest( + current -> { + Set previous = new HashSet<>(containers); + Set updated = new HashSet<>(current); + + Set intersection = new HashSet<>(containers); + intersection.retainAll(updated); + + Set removed = new HashSet<>(previous); + removed.removeAll(intersection); + + Set added = new HashSet<>(updated); + added.removeAll(intersection); + + // does anything ever get modified in this scheme? + // notifyAsyncTargetDiscovery(EventKind.MODIFIED, sr); + + containers.removeAll(removed); + removed.forEach( + spec -> notifyAsyncTargetDiscovery(EventKind.LOST, convert(spec))); + + containers.addAll(added); + added.forEach( + spec -> notifyAsyncTargetDiscovery(EventKind.FOUND, convert(spec))); + }); + } + + private void doPodmanRequest(Consumer> successHandler) { + URI requestPath = URI.create("http://d/v3.0.0/libpod/containers/json"); + vertx.executeBlocking( + promise -> + webClient + .request( + HttpMethod.GET, + podmanSocket, + 80, + "localhost", + requestPath.toString()) + .addQueryParam( + "filters", + gson.toJson(Map.of("label", List.of(CRYOSTAT_LABEL)))) + // TODO make this configurable? + .timeout(5_000L) + .as(BodyCodec.string()) + .send( + ar -> { + if (ar.failed()) { + Throwable t = ar.cause(); + logger.error("Podman API request failed", t); + promise.fail(t); + return; + } + HttpResponse response = ar.result(); + successHandler.accept( + gson.fromJson( + response.body(), + new TypeToken< + List>() {})); + promise.complete(); + })); + } + + private ServiceRef convert(ContainerSpec desc) { + String connectUrl = desc.Labels.get(CRYOSTAT_LABEL); + URI serviceUrl; + try { + serviceUrl = new URI(connectUrl); + } catch (URISyntaxException e) { + logger.warn(e); + return null; + } + + ServiceRef serviceRef = + new ServiceRef( + null, serviceUrl, Optional.ofNullable(desc.Names.get(0)).orElse(desc.Id)); + + Map cryostatAnnotations = new HashMap<>(); + cryostatAnnotations.put(AnnotationKey.REALM, REALM); + + String host = serviceUrl.getHost(); + int port = serviceUrl.getPort(); + if ("service".equals(serviceUrl.getScheme())) { + try { + JMXServiceURL jmx = new JMXServiceURL(serviceUrl.toString()); + if (URIUtil.isRmiUrl(jmx)) { + serviceUrl = URIUtil.getRmiTarget(jmx); + host = serviceUrl.getHost(); + port = serviceUrl.getPort(); + } else { + host = jmx.getHost(); + port = jmx.getPort(); + } + } catch (URISyntaxException | MalformedURLException e) { + logger.warn(e); + return null; + } + } + cryostatAnnotations.put(AnnotationKey.HOST, host); + cryostatAnnotations.put(AnnotationKey.PORT, Integer.toString(port)); + + serviceRef.setCryostatAnnotations(cryostatAnnotations); + // TODO perform podman inspection query to populate annotations + // serviceRef.setPlatformAnnotations(); + serviceRef.setLabels(desc.Labels); + + return serviceRef; + } + + private List convert(Collection descs) { + return descs.stream().map(this::convert).filter(Objects::nonNull).toList(); + } + + @Override + public EnvironmentNode getDiscoveryTree() { + List children = new ArrayList<>(); + + Map pods = new HashMap<>(); + for (ContainerSpec container : containers) { + ServiceRef sr = convert(container); + if (sr == null) { + continue; + } + TargetNode target = new TargetNode(BaseNodeType.JVM, sr); + String podName = container.PodName; + if (StringUtils.isNotBlank(podName)) { + pods.computeIfAbsent(podName, n -> new EnvironmentNode(n, PodmanNodeType.POD)); + pods.get(podName).addChildNode(target); + } else { + children.add(target); + } + } + children.addAll(pods.values()); + return new EnvironmentNode(REALM, BaseNodeType.REALM, Collections.emptyMap(), children); + } + + static record PortSpec( + long container_port, String host_ip, long host_port, String protocol, long range) {} + + static record ContainerSpec( + String Id, + String Image, + Map Labels, + List Names, + long Pid, + String Pod, + String PodName, + List Ports, + long StartedAt, + String State) {} + + public enum PodmanNodeType implements NodeType { + POD("Pod"), + ; + + private final String label; + + PodmanNodeType(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } + + @Override + public String getKind() { + return label; + } + } +} diff --git a/src/main/java/io/cryostat/platform/internal/PodmanPlatformStrategy.java b/src/main/java/io/cryostat/platform/internal/PodmanPlatformStrategy.java new file mode 100644 index 0000000000..c5603a0a48 --- /dev/null +++ b/src/main/java/io/cryostat/platform/internal/PodmanPlatformStrategy.java @@ -0,0 +1,104 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.platform.internal; + +import io.cryostat.core.log.Logger; +import io.cryostat.core.sys.FileSystem; +import io.cryostat.net.AuthManager; + +import com.google.gson.Gson; +import com.sun.security.auth.module.UnixSystem; +import dagger.Lazy; +import io.vertx.core.Vertx; +import io.vertx.core.net.SocketAddress; + +class PodmanPlatformStrategy implements PlatformDetectionStrategy { + + private final Logger logger; + private final Lazy authMgr; + private final Vertx vertx; + private final Gson gson; + private final FileSystem fs; + + PodmanPlatformStrategy( + Logger logger, + Lazy authMgr, + Vertx vertx, + Gson gson, + FileSystem fs) { + this.logger = logger; + this.authMgr = authMgr; + this.vertx = vertx; + this.gson = gson; + this.fs = fs; + } + + @Override + public int getPriority() { + return PRIORITY_PLATFORM + 5; + } + + @Override + public boolean isAvailable() { + String socketPath = getSocketPath(); + logger.info("Testing {} Availability via {}", getClass().getSimpleName(), socketPath); + // TODO check that the service is actually available on the socket using an HTTP request + boolean available = fs.isReadable(fs.pathOf(socketPath)); + logger.info("{} available? {}", getClass().getSimpleName(), available); + return available; + } + + @Override + public PodmanPlatformClient getPlatformClient() { + logger.info("Selected {} Strategy", getClass().getSimpleName()); + String socketPath = getSocketPath(); + SocketAddress podmanPath = SocketAddress.domainSocketAddress(socketPath); + return new PodmanPlatformClient(vertx, podmanPath, gson, logger); + } + + @Override + public AuthManager getAuthManager() { + return authMgr.get(); + } + + private static String getSocketPath() { + long uid = new UnixSystem().getUid(); + String socketPath = String.format("/run/user/%d/podman/podman.sock", uid); + return socketPath; + } +} diff --git a/src/main/java/io/cryostat/util/URIUtil.java b/src/main/java/io/cryostat/util/URIUtil.java index 0d7c54756c..b81ae2fec6 100644 --- a/src/main/java/io/cryostat/util/URIUtil.java +++ b/src/main/java/io/cryostat/util/URIUtil.java @@ -57,10 +57,15 @@ public static URI convert(JMXServiceURL serviceUrl) throws URISyntaxException { return new URI(serviceUrl.toString()); } - public static URI getRmiTarget(JMXServiceURL serviceUrl) throws URISyntaxException { + public static boolean isRmiUrl(JMXServiceURL serviceUrl) { String rmiPart = "/jndi/rmi://"; String pathPart = serviceUrl.getURLPath(); - if (!pathPart.startsWith(rmiPart)) { + return pathPart.startsWith(rmiPart); + } + + public static URI getRmiTarget(JMXServiceURL serviceUrl) throws URISyntaxException { + String pathPart = serviceUrl.getURLPath(); + if (!isRmiUrl(serviceUrl)) { throw new IllegalArgumentException(serviceUrl.getURLPath()); } return new URI(pathPart.substring("/jndi/".length(), pathPart.length()));