diff --git a/extensions/oidc/deployment/pom.xml b/extensions/oidc/deployment/pom.xml index 43c0bf86cfc50..9f7cecad85844 100644 --- a/extensions/oidc/deployment/pom.xml +++ b/extensions/oidc/deployment/pom.xml @@ -63,6 +63,10 @@ io.quarkus quarkus-junit4-mock + + io.quarkus + quarkus-devservices-common + io.quarkus diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/DevServicesConfig.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/DevServicesConfig.java index 2bbed7946eca6..754bc3e7db2d2 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/DevServicesConfig.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/DevServicesConfig.java @@ -25,7 +25,34 @@ public class DevServicesConfig { * The container image name to use, for container based DevServices providers. */ @ConfigItem(defaultValue = "quay.io/keycloak/keycloak:14.0.0") - public Optional imageName; + public String imageName; + + /** + * Indicates if the Keycloak container managed by Quarkus Dev Services is shared. + * When shared, Quarkus looks for running containers using label-based service discovery. + * If a matching container is found, it is used, and so a second one is not started. + * Otherwise, Dev Services for Keycloak starts a new container. + *

+ * The discovery uses the {@code quarkus-dev-service-label} label. + * The value is configured using the {@code service-name} property. + *

+ * Container sharing is only used in dev mode. + */ + @ConfigItem(defaultValue = "true") + public boolean shared; + + /** + * The value of the {@code quarkus-dev-service-keycloak} label attached to the started container. + * This property is used when {@code shared} is set to {@code true}. + * In this case, before starting a container, Dev Services for Keycloak looks for a container with the + * {@code quarkus-dev-service-keycloak} label + * set to the configured value. If found, it will use this container instead of starting a new one. Otherwise it + * starts a new container with the {@code quarkus-dev-service-keycloak} label set to the specified value. + *

+ * Container sharing is only used in dev mode. + */ + @ConfigItem(defaultValue = "quarkus") + public String serviceName; /** * The class or file system path to a Keycloak realm file which will be used to initialize Keycloak. @@ -41,8 +68,6 @@ public class DevServicesConfig { /** * The Keycloak realm. * This property will be used to create the realm if the realm file pointed to by the 'realm-path' property does not exist. - * Setting this property is recommended even if realm file exists - * for `quarkus.oidc.auth-server-url` property be correctly calculated. */ @ConfigItem(defaultValue = "quarkus") public String realmName; diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevServicesProcessor.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevServicesProcessor.java index 7c67693cc96d9..1a0658f65fe06 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevServicesProcessor.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevServicesProcessor.java @@ -15,6 +15,7 @@ import java.util.Map; import java.util.Optional; import java.util.OptionalInt; +import java.util.function.Supplier; import java.util.stream.Collectors; import org.eclipse.microprofile.config.ConfigProvider; @@ -41,8 +42,11 @@ import io.quarkus.deployment.builditem.DevServicesConfigResultBuildItem; import io.quarkus.deployment.builditem.DevServicesSharedNetworkBuildItem; import io.quarkus.deployment.dev.devservices.GlobalDevServicesConfig; +import io.quarkus.devservices.common.ContainerAddress; +import io.quarkus.devservices.common.ContainerLocator; import io.quarkus.oidc.deployment.OidcBuildStep.IsEnabled; import io.quarkus.oidc.deployment.devservices.OidcDevServicesBuildItem; +import io.quarkus.runtime.LaunchMode; import io.quarkus.runtime.configuration.ConfigUtils; import io.vertx.core.Vertx; import io.vertx.core.http.HttpHeaders; @@ -64,7 +68,7 @@ public class KeycloakDevServicesProcessor { private static final String CLIENT_SECRET_CONFIG_KEY = CONFIG_PREFIX + "credentials.secret"; private static final String KEYCLOAK_URL_KEY = "keycloak.url"; - private static final int KEYCLOAK_EXPOSED_PORT = 8080; + private static final int KEYCLOAK_PORT = 8080; private static final String JAVA_OPTS = "JAVA_OPTS"; private static final String KEYCLOAK_DOCKER_REALM_PATH = "/tmp/realm.json"; private static final String KEYCLOAK_USER_PROP = "KEYCLOAK_USER"; @@ -76,6 +80,14 @@ public class KeycloakDevServicesProcessor { private static final String KEYCLOAK_DB_VENDOR = "H2"; private static final String OIDC_USERS = "oidc.users"; + /** + * Label to add to shared Dev Service for Keycloak running in containers. + * This allows other applications to discover the running service and use it instead of starting a new instance. + */ + private static final String DEV_SERVICE_LABEL = "quarkus-dev-service-keycloak"; + private static final ContainerLocator keycloakDevModeContainerLocator = new ContainerLocator(DEV_SERVICE_LABEL, + KEYCLOAK_PORT); + private static volatile List closeables; private static volatile boolean first = true; private static volatile String capturedKeycloakUrl; @@ -129,7 +141,7 @@ public KeycloakDevServicesConfigBuildItem startKeycloakContainer( return null; } - closeables = Collections.singletonList(startResult.closeable); + closeables = startResult.closeable != null ? Collections.singletonList(startResult.closeable) : null; if (first) { first = false; @@ -171,11 +183,11 @@ public void run() { LOG.info("Dev Services for Keycloak started."); - return prepareConfiguration(!startResult.realmFileExists, devServices); + return prepareConfiguration(startResult.createDefaultRealm, devServices, startResult.shared); } private KeycloakDevServicesConfigBuildItem prepareConfiguration(boolean createRealm, - BuildProducer devServices) { + BuildProducer devServices, boolean shared) { final String authServerUrl = capturedKeycloakUrl + "/realms/" + capturedDevServicesConfiguration.realmName; String oidcClientId = getOidcClientId(); @@ -223,55 +235,81 @@ private StartResult startContainer(boolean useSharedContainer) { return null; } - String imageName = capturedDevServicesConfiguration.imageName.get(); - DockerImageName dockerImageName = DockerImageName.parse(imageName) - .asCompatibleSubstituteFor(imageName); - QuarkusOidcContainer oidcContainer = new QuarkusOidcContainer(dockerImageName, - capturedDevServicesConfiguration.port, - useSharedContainer, - capturedDevServicesConfiguration.realmPath, capturedDevServicesConfiguration.javaOpts); - - oidcContainer.start(); - - String url = "http://" + oidcContainer.getHost() + ":" + oidcContainer.getPort(); - return new StartResult(url, - oidcContainer.realmFileExists, - new Closeable() { - @Override - public void close() { - oidcContainer.close(); - - LOG.info("Dev Services for Keycloak shut down."); - } - }); + final Optional maybeContainerAddress = keycloakDevModeContainerLocator.locateContainer( + capturedDevServicesConfiguration.realmName, + capturedDevServicesConfiguration.shared, + LaunchMode.current()); + + final Supplier defaultKeycloakContainerSupplier = () -> { + String imageName = capturedDevServicesConfiguration.imageName; + DockerImageName dockerImageName = DockerImageName.parse(imageName) + .asCompatibleSubstituteFor(imageName); + QuarkusOidcContainer oidcContainer = new QuarkusOidcContainer(dockerImageName, + capturedDevServicesConfiguration.port, + useSharedContainer, + capturedDevServicesConfiguration.realmPath, + capturedDevServicesConfiguration.serviceName, + capturedDevServicesConfiguration.shared, + capturedDevServicesConfiguration.javaOpts); + + oidcContainer.start(); + + String url = "http://" + oidcContainer.getHost() + ":" + oidcContainer.getPort(); + return new StartResult(url, + !oidcContainer.realmFileExists, + new Closeable() { + @Override + public void close() { + oidcContainer.close(); + + LOG.info("Dev Services for Keycloak shut down."); + } + }, + false); + }; + + return maybeContainerAddress + .map(containerAddress -> new StartResult(getSharedContainerUrl(containerAddress), false, null, true)) + .orElseGet(defaultKeycloakContainerSupplier); + } + + private String getSharedContainerUrl(ContainerAddress containerAddress) { + return "http://" + ("0.0.0.0".equals(containerAddress.getHost()) ? "localhost" : containerAddress.getHost()) + + ":" + containerAddress.getPort(); } private static class StartResult { private final String url; - private final boolean realmFileExists; + private final boolean createDefaultRealm; private final Closeable closeable; + private final boolean shared; - public StartResult(String url, boolean realmFileExists, Closeable closeable) { + public StartResult(String url, boolean createDefaultRealm, Closeable closeable, boolean shared) { this.url = url; - this.realmFileExists = realmFileExists; + this.createDefaultRealm = createDefaultRealm; this.closeable = closeable; + this.shared = shared; } } private static class QuarkusOidcContainer extends GenericContainer { private final OptionalInt fixedExposedPort; private final boolean useSharedNetwork; - private final Optional realm; + private final Optional realmPath; + private final String containerLabelValue; + private final Optional javaOpts; + private final boolean sharedContainer; private boolean realmFileExists; private String hostName = null; - private final Optional javaOpts; public QuarkusOidcContainer(DockerImageName dockerImageName, OptionalInt fixedExposedPort, boolean useSharedNetwork, - Optional realm, Optional javaOpts) { + Optional realmPath, String containerLabelValue, boolean sharedContainer, Optional javaOpts) { super(dockerImageName); this.fixedExposedPort = fixedExposedPort; this.useSharedNetwork = useSharedNetwork; - this.realm = realm; + this.realmPath = realmPath; + this.containerLabelValue = containerLabelValue; + this.sharedContainer = sharedContainer; this.javaOpts = javaOpts; } @@ -287,12 +325,16 @@ protected void configure() { setNetworkAliases(Collections.singletonList(hostName)); } else { if (fixedExposedPort.isPresent()) { - addFixedExposedPort(fixedExposedPort.getAsInt(), KEYCLOAK_EXPOSED_PORT); + addFixedExposedPort(fixedExposedPort.getAsInt(), KEYCLOAK_PORT); } else { - addExposedPort(KEYCLOAK_EXPOSED_PORT); + addExposedPort(KEYCLOAK_PORT); } } + if (sharedContainer && LaunchMode.current() == LaunchMode.DEVELOPMENT) { + withLabel(DEV_SERVICE_LABEL, containerLabelValue); + } + addEnv(KEYCLOAK_USER_PROP, KEYCLOAK_ADMIN_USER); addEnv(KEYCLOAK_PASSWORD_PROP, KEYCLOAK_ADMIN_PASSWORD); addEnv(KEYCLOAK_VENDOR_PROP, KEYCLOAK_DB_VENDOR); @@ -300,24 +342,24 @@ protected void configure() { addEnv(JAVA_OPTS, javaOpts.get()); } - if (realm.isPresent()) { - if (Thread.currentThread().getContextClassLoader().getResource(realm.get()) != null) { + if (realmPath.isPresent()) { + if (Thread.currentThread().getContextClassLoader().getResource(realmPath.get()) != null) { realmFileExists = true; - withClasspathResourceMapping(realm.get(), KEYCLOAK_DOCKER_REALM_PATH, BindMode.READ_ONLY); + withClasspathResourceMapping(realmPath.get(), KEYCLOAK_DOCKER_REALM_PATH, BindMode.READ_ONLY); addEnv(KEYCLOAK_IMPORT_PROP, KEYCLOAK_DOCKER_REALM_PATH); } else { - Path filePath = Paths.get(realm.get()); + Path filePath = Paths.get(realmPath.get()); if (Files.exists(filePath)) { realmFileExists = true; - withFileSystemBind(realm.get(), KEYCLOAK_DOCKER_REALM_PATH, BindMode.READ_ONLY); + withFileSystemBind(realmPath.get(), KEYCLOAK_DOCKER_REALM_PATH, BindMode.READ_ONLY); addEnv(KEYCLOAK_IMPORT_PROP, KEYCLOAK_DOCKER_REALM_PATH); } else { - LOG.debugf("Realm %s resource is not available", realm.get()); + LOG.debugf("Realm %s resource is not available", realmPath.get()); } } } - super.setWaitStrategy(Wait.forHttp("/auth").forPort(KEYCLOAK_EXPOSED_PORT)); + super.setWaitStrategy(Wait.forHttp("/auth").forPort(KEYCLOAK_PORT)); } @Override @@ -330,7 +372,7 @@ public String getHost() { public int getPort() { if (useSharedNetwork) { - return KEYCLOAK_EXPOSED_PORT; + return KEYCLOAK_PORT; } if (fixedExposedPort.isPresent()) { return fixedExposedPort.getAsInt();