diff --git a/arconia-bom/build.gradle b/arconia-bom/build.gradle index c5e29ae1..485d6e5c 100644 --- a/arconia-bom/build.gradle +++ b/arconia-bom/build.gradle @@ -23,6 +23,7 @@ dependencies { api project(":arconia-dev:arconia-dev-services:arconia-dev-services-artemis") api project(":arconia-dev:arconia-dev-services:arconia-dev-services-docling") api project(":arconia-dev:arconia-dev-services:arconia-dev-services-kafka") + api project(":arconia-dev:arconia-dev-services:arconia-dev-services-keycloak") api project(":arconia-dev:arconia-dev-services:arconia-dev-services-lgtm") api project(":arconia-dev:arconia-dev-services:arconia-dev-services-lldap") api project(":arconia-dev:arconia-dev-services:arconia-dev-services-mariadb") diff --git a/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/build.gradle b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/build.gradle new file mode 100644 index 00000000..ac0cf33a --- /dev/null +++ b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/build.gradle @@ -0,0 +1,34 @@ +plugins { + id "code-quality-conventions" + id "java-conventions" + id "sbom-conventions" + id "release-conventions" +} + +dependencies { + annotationProcessor "org.springframework.boot:spring-boot-autoconfigure-processor" + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" + + api project(":arconia-dev:arconia-dev-services:arconia-dev-services-core") + api "org.springframework.boot:spring-boot-starter" + api "org.springframework.boot:spring-boot-testcontainers" + api "com.github.dasniko:testcontainers-keycloak:4.1.1" + + optional "org.springframework.boot:spring-boot-devtools" + + testImplementation project(":arconia-dev:arconia-dev-services:arconia-dev-services-tests") + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation "org.testcontainers:testcontainers-junit-jupiter" + testRuntimeOnly "org.junit.platform:junit-platform-launcher" +} + +publishing { + publications { + mavenJava(MavenPublication) { + pom { + name = "Arconia Dev Services Keycloak" + description = "Arconia Dev Services Keycloak." + } + } + } +} diff --git a/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/ArconiaKeycloakContainer.java b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/ArconiaKeycloakContainer.java new file mode 100644 index 00000000..ba1d85d3 --- /dev/null +++ b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/ArconiaKeycloakContainer.java @@ -0,0 +1,59 @@ +package io.arconia.dev.services.keycloak; + +import com.github.dockerjava.api.command.InspectContainerResponse; + +import org.testcontainers.utility.DockerImageName; + +import dasniko.testcontainers.keycloak.KeycloakContainer; +import io.arconia.dev.services.core.container.ContainerConfigurer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.StringUtils; + + +/** + * A {@link KeycloakContainer} specialized for Arconia Dev Services. + */ +public final class ArconiaKeycloakContainer extends KeycloakContainer { + private static final Logger logger = LoggerFactory.getLogger(ArconiaKeycloakContainer.class); + + private final KeycloakDevServicesProperties properties; + + static final String COMPATIBLE_IMAGE_NAME = "keycloak"; + + static final int WEB_CONSOLE_PORT = 8080; + + public ArconiaKeycloakContainer(KeycloakDevServicesProperties properties) { + super(DockerImageName.parse(properties.getImageName()).asCompatibleSubstituteFor(COMPATIBLE_IMAGE_NAME).asCanonicalNameString()); + this.properties = properties; + + ContainerConfigurer.base(this, properties); + + + this.withAdminUsername(StringUtils.hasText(properties.getUsername()) ? properties.getUsername() : KeycloakDevServicesProperties.DEFAULT_USERNAME); + this.withAdminPassword(StringUtils.hasText(properties.getPassword()) ? properties.getPassword() : KeycloakDevServicesProperties.DEFAULT_PASSWORD); +} + + @Override + protected void configure() { + super.configure(); + if (properties.getPort() > 0) { + addFixedExposedPort(properties.getPort(), WEB_CONSOLE_PORT); + } + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + super.containerIsStarted(containerInfo); + logger.info("Keycloak Web Console: {}", getManagementConsoleUrl()); + } + + + /** + * Retrieve the URL of the Keycloak Web Console. + */ + String getManagementConsoleUrl() { + return "http://" + getHost() + ":" + getMappedPort(WEB_CONSOLE_PORT) + "/console"; + } +} diff --git a/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/KeycloakConnectionDetails.java b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/KeycloakConnectionDetails.java new file mode 100644 index 00000000..614011f0 --- /dev/null +++ b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/KeycloakConnectionDetails.java @@ -0,0 +1,14 @@ +package io.arconia.dev.services.keycloak; + +import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; + +/** + * Connection details for Keycloak containers. + */ +public interface KeycloakConnectionDetails extends ConnectionDetails { + + String getServerUrl(); + + String getIssuerUri(); + +} diff --git a/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/KeycloakContainerConnectionDetailsFactory.java b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/KeycloakContainerConnectionDetailsFactory.java new file mode 100644 index 00000000..a4398857 --- /dev/null +++ b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/KeycloakContainerConnectionDetailsFactory.java @@ -0,0 +1,44 @@ +package io.arconia.dev.services.keycloak; + +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource; + +import dasniko.testcontainers.keycloak.KeycloakContainer; + +/** + * Factory for creating {@link KeycloakConnectionDetails} for Keycloak containers. + */ +public class KeycloakContainerConnectionDetailsFactory + extends ContainerConnectionDetailsFactory { + + // private static final String CONNECTION_NAME = "keycloak"; + + // public KeycloakContainerConnectionDetailsFactory() { + // super(CONNECTION_NAME); + // } + + @Override + protected KeycloakConnectionDetails getContainerConnectionDetails(ContainerConnectionSource source) { + return new KeycloakContainerConnectionDetails(source); + } + + private static final class KeycloakContainerConnectionDetails extends ContainerConnectionDetails + implements KeycloakConnectionDetails { + + private KeycloakContainerConnectionDetails(ContainerConnectionSource source) { + super(source); + } + + @Override + public String getServerUrl() { + return getContainer().getAuthServerUrl(); + } + + @Override + public String getIssuerUri() { + String auth = getContainer().getAuthServerUrl(); + return auth.endsWith("/") ? auth + "realms/master" : auth + "/realms/master"; + } + } + +} diff --git a/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/KeycloakContainerRegistrar.java b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/KeycloakContainerRegistrar.java new file mode 100644 index 00000000..1c3a7108 --- /dev/null +++ b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/KeycloakContainerRegistrar.java @@ -0,0 +1,134 @@ +package io.arconia.dev.services.keycloak; + +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; +import org.springframework.beans.factory.support.DefaultSingletonBeanRegistry; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.boot.context.properties.bind.BindException; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.EnvironmentAware; +import org.springframework.core.Ordered; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.Environment; +import org.springframework.core.env.MapPropertySource; + +import dasniko.testcontainers.keycloak.KeycloakContainer; + +/** + * Starts a Keycloak Testcontainers instance early, publishes derived properties + * and registers the started instance as a singleton with a disposable. + */ +public final class KeycloakContainerRegistrar implements BeanDefinitionRegistryPostProcessor, EnvironmentAware, Ordered { + + private static final Logger logger = LoggerFactory.getLogger(KeycloakContainerRegistrar.class); + + private Environment environment; + + @Override + public void setEnvironment(Environment environment) { + this.environment = environment; + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } + + @Override + public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { + if (!registry.containsBeanDefinition("keycloakContainer")) { + RootBeanDefinition bd = new RootBeanDefinition(KeycloakContainer.class); + bd.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + bd.setSynthetic(true); + registry.registerBeanDefinition("keycloakContainer", bd); + } + } + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { + var binder = Binder.get(environment); + + KeycloakDevServicesProperties properties; + + try { + properties = binder.bind("arconia.dev.services.keycloak", KeycloakDevServicesProperties.class) + .orElse(new KeycloakDevServicesProperties()); + } catch (BindException ex) { + logger.warn("Failed to bind KeycloakDevServicesProperties; using defaults", ex); + properties = new KeycloakDevServicesProperties(); + } + + if (Boolean.FALSE.equals(properties.isEnabled())) { + logger.debug("Keycloak dev services disabled via properties"); + return; + } + + synchronized (KeycloakContainerRegistrar.class) { + if (beanFactory.containsSingleton("keycloakContainer")) { + logger.debug("Keycloak container already registered as singleton"); + return; + } + + var container = new ArconiaKeycloakContainer(properties); + + logger.info("Starting Keycloak dev-services container (image={})", properties.getImageName()); + container.start(); + + // inject minimal derived properties so other auto-config can bind + if (environment instanceof ConfigurableEnvironment) { + Map map = new HashMap<>(); + try { + String issuer = container.getAuthServerUrl(); + if (issuer != null) { + // Resource server expects this property + map.put("spring.security.oauth2.resourceserver.jwt.issuer-uri", issuer); + } + } catch (Exception ex) { + logger.warn("Failed to derive Keycloak properties from container", ex); + } + + if (!map.isEmpty()) { + ((ConfigurableEnvironment) environment).getPropertySources() + .addFirst(new MapPropertySource("arconia-keycloak", map)); + } + } + + // register the started instance and arrange for shutdown + beanFactory.registerSingleton("keycloakContainer", container); + + if (beanFactory instanceof DefaultSingletonBeanRegistry) { + ((DefaultSingletonBeanRegistry) beanFactory).registerDisposableBean("keycloakContainer", new DisposableBean() { + @Override + public void destroy() { + try { + container.stop(); + } catch (Exception ex) { + logger.warn("Error stopping Keycloak container", ex); + } + } + }); + } else { + // Fallback: ensure container is stopped on JVM shutdown + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + container.stop(); + } catch (Exception ex) { + logger.warn("Error stopping Keycloak container during shutdown hook", ex); + } + })); + } + + logger.info("Keycloak dev-services container started and registered"); + } + } + +} diff --git a/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/KeycloakDevServicesAutoConfiguration.java b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/KeycloakDevServicesAutoConfiguration.java new file mode 100644 index 00000000..fec95578 --- /dev/null +++ b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/KeycloakDevServicesAutoConfiguration.java @@ -0,0 +1,57 @@ +package io.arconia.dev.services.keycloak; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.env.Environment; + +import dasniko.testcontainers.keycloak.KeycloakContainer; +import io.arconia.dev.services.core.autoconfigure.ConditionalOnDevServicesEnabled; +import io.arconia.dev.services.core.autoconfigure.DevServicesAutoConfiguration; +import io.arconia.dev.services.core.registration.DevServicesRegistrar; +import io.arconia.dev.services.core.registration.DevServicesRegistry; +import io.arconia.dev.services.keycloak.KeycloakDevServicesAutoConfiguration.KeycloakDevServicesRegistrar; + +/** + * Auto-configuration for Keycloak Dev Services. + */ +@AutoConfiguration(after = DevServicesAutoConfiguration.class, before = ServiceConnectionAutoConfiguration.class) +@ConditionalOnDevServicesEnabled("keycloak") +@EnableConfigurationProperties(KeycloakDevServicesProperties.class) +@Import(KeycloakDevServicesRegistrar.class) +public final class KeycloakDevServicesAutoConfiguration { + + + static class KeycloakDevServicesRegistrar extends DevServicesRegistrar { + + @Override + protected void registerDevServices(DevServicesRegistry registry, Environment environment) { + var properties = bindProperties(KeycloakDevServicesProperties.CONFIG_PREFIX, KeycloakDevServicesProperties.class); + + registry.registerDevService(service -> service + .name("keycloak") + .description("Keycloak Dev Service") + .container(container -> container + .type(KeycloakContainer.class) + .supplier(() -> new ArconiaKeycloakContainer(properties)) + )); + + } + + } + + @Bean + @ServiceConnection("keycloak") + @ConditionalOnBean(KeycloakContainer.class) + @ConditionalOnMissingBean + KeycloakContainer keycloakServiceConnection(ObjectProvider provider) { + return provider.getIfAvailable(); + } +} + diff --git a/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/KeycloakDevServicesProperties.java b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/KeycloakDevServicesProperties.java new file mode 100644 index 00000000..bea7a921 --- /dev/null +++ b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/KeycloakDevServicesProperties.java @@ -0,0 +1,224 @@ +package io.arconia.dev.services.keycloak; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import io.arconia.dev.services.api.config.BaseDevServicesProperties; +import io.arconia.dev.services.api.config.ResourceMapping; +import io.arconia.dev.services.api.config.VolumeMapping; + +/** + * Properties for the Keycloak Dev Services. + */ +@ConfigurationProperties(prefix = KeycloakDevServicesProperties.CONFIG_PREFIX) +public class KeycloakDevServicesProperties implements BaseDevServicesProperties { + public static final String CONFIG_PREFIX = "arconia.dev.services.keycloak"; + + static final String DEFAULT_USERNAME = "keycloak"; + static final String DEFAULT_PASSWORD = "keycloak"; + + /** + * Whether the dev service is enabled. + */ + private boolean enabled = true; + + /** + * Full name of the container image used in the dev service. + */ + private String imageName = "quay.io/keycloak/keycloak:26.5.0"; + + /** + * Environment variables to set in the service. + */ + private Map environment = new HashMap<>(); + + /** + * Network aliases to assign to the dev service container. + */ + private List networkAliases = new ArrayList<>(); + + /** + * Fixed port for exposing the Keycloak' TCP port to the host. + * When it's 0 (default), a random available port is assigned dynamically. + */ + private int port = 0; + + /** + * Resources from the classpath or host filesystem to copy into the container. + * They can be files or directories that will be copied to the specified + * destination path inside the container at startup and are immutable (read-only). + */ + private List resources = new ArrayList<>( + List.of( + new ResourceMapping("classpath:keycloak/realms", "/opt/keycloak/data/import"), + new ResourceMapping("classpath:keycloak/providers", "/opt/keycloak/providers") + ) + ); + + /** + * Whether the dev service is shared among applications. + * Only applicable in dev mode. + */ + private boolean shared = true; + + /** + * Maximum waiting time for the service to start. + */ + private Duration startupTimeout = Duration.ofMinutes(2); + + /** + * Files or directories to mount from the host filesystem into the container. + * They are mounted at the specified destination path inside the container + * at startup and are mutable (read-write). Changes in either the host + * or the container will be immediately reflected in the other. + */ + private List volumes = new ArrayList<>(); + + /** + * Fixed port for exposing the Keycloak Management Console to the host. + * When it's 0 (default), a random available port is assigned dynamically. + */ + private int managementConsolePort = 0; + + /** + * Username for the Keycloak administrator user. + */ + private String username = DEFAULT_USERNAME; + + /** + * Password for the Keycloak§ administrator user. + */ + private String password = DEFAULT_PASSWORD; + + /** + * Realm name to use for issuer URI construction. + */ + private String realm = "master"; + + @Override + public boolean isEnabled() { + return enabled; + } + + @Override + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + @Override + public String getImageName() { + return imageName; + } + + @Override + public void setImageName(String imageName) { + this.imageName = imageName; + } + + @Override + public Map getEnvironment() { + return environment; + } + + @Override + public void setEnvironment(Map environment) { + this.environment = environment; + } + + @Override + public List getNetworkAliases() { + return networkAliases; + } + + @Override + public void setNetworkAliases(List networkAliases) { + this.networkAliases = networkAliases; + } + + @Override + public int getPort() { + return port; + } + + @Override + public void setPort(int port) { + this.port = port; + } + + @Override + public List getResources() { + return resources; + } + + @Override + public void setResources(List resources) { + this.resources = resources; + } + + @Override + public boolean isShared() { + return shared; + } + + @Override + public void setShared(boolean shared) { + this.shared = shared; + } + + @Override + public Duration getStartupTimeout() { + return startupTimeout; + } + + @Override + public void setStartupTimeout(Duration startupTimeout) { + this.startupTimeout = startupTimeout; + } + + @Override + public List getVolumes() { + return volumes; + } + + @Override + public void setVolumes(List volumes) { + this.volumes = volumes; + } + + public int getManagementConsolePort() { + return managementConsolePort; + } + + public void setManagementConsolePort(int managementConsolePort) { + this.managementConsolePort = managementConsolePort; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getRealm() { + return realm; + } + + public void setRealm(String realm) { + this.realm = realm; + } +} diff --git a/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/package-info.java b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/package-info.java new file mode 100644 index 00000000..c1b63c22 --- /dev/null +++ b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/java/io/arconia/dev/services/keycloak/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package io.arconia.dev.services.keycloak; + +import org.jspecify.annotations.NullMarked; diff --git a/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/resources/META-INF/spring.factories b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..6e67edbd --- /dev/null +++ b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory=io.arconia.dev.services.keycloak.KeycloakContainerConnectionDetailsFactory diff --git a/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..15c48b1d --- /dev/null +++ b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +io.arconia.dev.services.keycloak.KeycloakDevServicesAutoConfiguration diff --git a/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/test/java/io/arconia/dev/services/keycloak/KeycloakDevServicesAutoConfigurationIT.java b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/test/java/io/arconia/dev/services/keycloak/KeycloakDevServicesAutoConfigurationIT.java new file mode 100644 index 00000000..d1a8a770 --- /dev/null +++ b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/test/java/io/arconia/dev/services/keycloak/KeycloakDevServicesAutoConfigurationIT.java @@ -0,0 +1,82 @@ +package io.arconia.dev.services.keycloak; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.commons.lang3.ArrayUtils; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.EnabledIfDockerAvailable; + +import dasniko.testcontainers.keycloak.KeycloakContainer; +import io.arconia.dev.services.tests.BaseDevServicesAutoConfigurationIT; + +/** + * Integration tests for {@link KeycloakDevServicesAutoConfiguration}. + */ +@EnabledIfDockerAvailable +class KeycloakDevServicesAutoConfigurationIT extends BaseDevServicesAutoConfigurationIT { + + private static final ApplicationContextRunner contextRunner = defaultContextRunner(KeycloakDevServicesAutoConfiguration.class); + + @Override + protected ApplicationContextRunner getContextRunner() { + return contextRunner; + } + + @Override + protected Class getAutoConfigurationClass() { + return KeycloakDevServicesAutoConfiguration.class; + } + + @Override + protected Class> getContainerClass() { + return KeycloakContainer.class; + } + + @Override + protected String getServiceName() { + return "keycloak"; + } + + @Test + void containerAvailableInDevMode() { + getContextRunner() + .withSystemProperties("arconia.bootstrap.mode=dev") + .run(context -> { + assertThat(context).hasSingleBean(getContainerClass()); + var container = (KeycloakContainer) context.getBean(getContainerClass()); + assertThat(container.getDockerImageName()).contains(ArconiaKeycloakContainer.COMPATIBLE_IMAGE_NAME); + assertThat(container.getEnv()).isEmpty(); + assertThat(container.getNetworkAliases()).hasSize(1); + assertThat(container.isShouldBeReused()).isTrue(); + assertThat(container.getBinds()).isEmpty(); + container.start(); + assertThat(container.getAdminUsername()).isEqualTo(KeycloakDevServicesProperties.DEFAULT_USERNAME); + assertThat(container.getAdminPassword()).isEqualTo(KeycloakDevServicesProperties.DEFAULT_PASSWORD); + container.stop(); + + assertThatHasSingletonScope(context); + }); + } + + @Test + void containerConfigurationApplied() { + String[] properties = ArrayUtils.addAll(commonConfigurationProperties(), + "arconia.dev.services.%s.username=myusername".formatted(getServiceName()), + "arconia.dev.services.%s.password=mypassword".formatted(getServiceName()) + ); + + getContextRunner() + .withPropertyValues(properties) + .run(context -> { + var container = (KeycloakContainer) context.getBean(getContainerClass()); + container.start(); + assertThatConfigurationIsApplied(container); + assertThat(container.getAdminUsername()).isEqualTo("myusername"); + assertThat(container.getAdminPassword()).isEqualTo("mypassword"); + container.stop(); + }); + } + +} diff --git a/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/test/java/io/arconia/dev/services/keycloak/KeycloakDevServicesPropertiesTests.java b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/test/java/io/arconia/dev/services/keycloak/KeycloakDevServicesPropertiesTests.java new file mode 100644 index 00000000..7bebac31 --- /dev/null +++ b/arconia-dev/arconia-dev-services/arconia-dev-services-keycloak/src/test/java/io/arconia/dev/services/keycloak/KeycloakDevServicesPropertiesTests.java @@ -0,0 +1,68 @@ +package io.arconia.dev.services.keycloak; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import io.arconia.dev.services.tests.BaseDevServicesPropertiesTests; + +/** + * Unit tests for {@link KeycloakDevServicesProperties}. + */ +class KeycloakDevServicesPropertiesTests extends BaseDevServicesPropertiesTests { + + + @Override + protected KeycloakDevServicesProperties createProperties() { + return new KeycloakDevServicesProperties(); + } + + @Override + protected DefaultValues getExpectedDefaults() { + return DefaultValues.builder() + .imageName(ArconiaKeycloakContainer.COMPATIBLE_IMAGE_NAME) + .shared(true) + .build(); + } + + + @Test + void shouldCreateInstanceWithServiceSpecificDefaultValues() { + KeycloakDevServicesProperties properties = createProperties(); + + assertThat(properties.getManagementConsolePort()).isEqualTo(0); + assertThat(properties.getUsername()).isEqualTo(KeycloakDevServicesProperties.DEFAULT_USERNAME); + assertThat(properties.getPassword()).isEqualTo(KeycloakDevServicesProperties.DEFAULT_PASSWORD); + } + + + + @Test + void shouldCreateInstanceWithDefaultValues() { + KeycloakDevServicesProperties properties = new KeycloakDevServicesProperties(); + + assertThat(properties.isEnabled()).isTrue(); + assertThat(properties.getImageName()).contains("keycloak/keycloak"); + assertThat(properties.getPort()).isEqualTo(0); + assertThat(properties.getEnvironment()).isEmpty(); + assertTrue(properties.isShared()); + assertThat(properties.getStartupTimeout()).isEqualTo(Duration.ofMinutes(2)); + assertThat(properties.getUsername()).isEqualTo(KeycloakDevServicesProperties.DEFAULT_USERNAME); + assertThat(properties.getPassword()).isEqualTo(KeycloakDevServicesProperties.DEFAULT_PASSWORD); + } + @Test + void shouldUpdateServiceSpecificValues() { + KeycloakDevServicesProperties properties = createProperties(); + + properties.setManagementConsolePort(ArconiaKeycloakContainer.WEB_CONSOLE_PORT); + properties.setUsername("myusername"); + properties.setPassword("mypassword"); + + assertThat(properties.getManagementConsolePort()).isEqualTo(ArconiaKeycloakContainer.WEB_CONSOLE_PORT); + assertThat(properties.getUsername()).isEqualTo("myusername"); + assertThat(properties.getPassword()).isEqualTo("mypassword"); + } +} diff --git a/settings.gradle b/settings.gradle index 9c53589e..1adeab21 100644 --- a/settings.gradle +++ b/settings.gradle @@ -21,6 +21,7 @@ include 'arconia-dev:arconia-dev-services:arconia-dev-services-tests' include 'arconia-dev:arconia-dev-services:arconia-dev-services-artemis' include 'arconia-dev:arconia-dev-services:arconia-dev-services-docling' include 'arconia-dev:arconia-dev-services:arconia-dev-services-kafka' +include 'arconia-dev:arconia-dev-services:arconia-dev-services-keycloak' include 'arconia-dev:arconia-dev-services:arconia-dev-services-lgtm' include 'arconia-dev:arconia-dev-services:arconia-dev-services-lldap' include 'arconia-dev:arconia-dev-services:arconia-dev-services-mariadb'