Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions arconia-bom/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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";
}
}
Original file line number Diff line number Diff line change
@@ -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();

}
Original file line number Diff line number Diff line change
@@ -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<KeycloakContainer, KeycloakConnectionDetails> {

// private static final String CONNECTION_NAME = "keycloak";

// public KeycloakContainerConnectionDetailsFactory() {
// super(CONNECTION_NAME);
// }

@Override
protected KeycloakConnectionDetails getContainerConnectionDetails(ContainerConnectionSource<KeycloakContainer> source) {
return new KeycloakContainerConnectionDetails(source);
}

private static final class KeycloakContainerConnectionDetails extends ContainerConnectionDetails<KeycloakContainer>
implements KeycloakConnectionDetails {

private KeycloakContainerConnectionDetails(ContainerConnectionSource<KeycloakContainer> 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";
}
}

}
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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");
}
}

}
Original file line number Diff line number Diff line change
@@ -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<KeycloakContainer> provider) {
return provider.getIfAvailable();
}
}

Loading