diff --git a/quarkus/extensions/pom.xml b/quarkus/extensions/pom.xml index 920a63ced7..19c86480b6 100644 --- a/quarkus/extensions/pom.xml +++ b/quarkus/extensions/pom.xml @@ -36,6 +36,10 @@ org.apache.commons commons-lang3 + + org.keycloak + keycloak-model-jpa + org.keycloak keycloak-services @@ -54,7 +58,7 @@ io.quarkus - quarkus-arc + quarkus-agroal jar diff --git a/quarkus/extensions/src/main/java/org/keycloak/connections/jpa/QuarkusJpaConnectionProvider.java b/quarkus/extensions/src/main/java/org/keycloak/connections/jpa/QuarkusJpaConnectionProvider.java new file mode 100644 index 0000000000..760bee3cd1 --- /dev/null +++ b/quarkus/extensions/src/main/java/org/keycloak/connections/jpa/QuarkusJpaConnectionProvider.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.connections.jpa; + +import org.jboss.logging.Logger; + +import javax.persistence.EntityManager; + +public class QuarkusJpaConnectionProvider implements JpaConnectionProvider { + + private static final Logger logger = Logger.getLogger(QuarkusJpaConnectionProvider.class); + private final EntityManager em; + + public QuarkusJpaConnectionProvider(EntityManager em) { + this.em = em; + } + + @Override + public EntityManager getEntityManager() { + return em; + } + + @Override + public void close() { + logger.trace("QuarkusJpaConnectionProvider close()"); + em.close(); + } + +} diff --git a/quarkus/extensions/src/main/java/org/keycloak/connections/jpa/QuarkusJpaConnectionProviderFactory.java b/quarkus/extensions/src/main/java/org/keycloak/connections/jpa/QuarkusJpaConnectionProviderFactory.java new file mode 100644 index 0000000000..7a912dbb53 --- /dev/null +++ b/quarkus/extensions/src/main/java/org/keycloak/connections/jpa/QuarkusJpaConnectionProviderFactory.java @@ -0,0 +1,393 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.connections.jpa; + +import org.hibernate.cfg.AvailableSettings; +import org.hibernate.resource.jdbc.spi.PhysicalConnectionHandlingMode; +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.ServerStartupError; +import org.keycloak.connections.jpa.updater.JpaUpdaterProvider; +import org.keycloak.connections.jpa.util.JpaUtils; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.KeycloakSessionTask; +import org.keycloak.models.KeycloakTransactionManager; +import org.keycloak.models.dblock.DBLockManager; +import org.keycloak.models.dblock.DBLockProvider; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.provider.ServerInfoAwareProviderFactory; +import org.keycloak.timer.TimerProvider; +import org.keycloak.transaction.JtaTransactionManagerLookup; + +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; +import javax.persistence.SynchronizationType; +import javax.sql.DataSource; +import java.io.File; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import javax.enterprise.inject.spi.CDI; + +/** + * @author Stian Thorgersen + */ +public class QuarkusJpaConnectionProviderFactory implements JpaConnectionProviderFactory, ServerInfoAwareProviderFactory { + + private static final Logger logger = Logger.getLogger(QuarkusJpaConnectionProviderFactory.class); + + enum MigrationStrategy { + UPDATE, VALIDATE, MANUAL + } + + private volatile EntityManagerFactory emf; + + private Config.Scope config; + + private Map operationalInfo; + + private boolean jtaEnabled; + private JtaTransactionManagerLookup jtaLookup; + + private KeycloakSessionFactory factory; + + @Override + public JpaConnectionProvider create(KeycloakSession session) { + logger.trace("Create QuarkusJpaConnectionProvider"); + lazyInit(session); + + EntityManager em; + if (!jtaEnabled) { + logger.trace("enlisting EntityManager in JpaKeycloakTransaction"); + em = emf.createEntityManager(); + } else { + + em = emf.createEntityManager(SynchronizationType.SYNCHRONIZED); + } + em = PersistenceExceptionConverter.create(em); + if (!jtaEnabled) session.getTransactionManager().enlist(new JpaKeycloakTransaction(em)); + return new QuarkusJpaConnectionProvider(em); + } + + @Override + public void close() { + if (emf != null) { + emf.close(); + } + } + + @Override + public String getId() { + return "quarkus"; + } + + @Override + public void init(Config.Scope config) { + this.config = config; + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + this.factory = factory; + checkJtaEnabled(factory); + + } + + protected void checkJtaEnabled(KeycloakSessionFactory factory) { + jtaLookup = (JtaTransactionManagerLookup) factory.getProviderFactory(JtaTransactionManagerLookup.class); + if (jtaLookup != null) { + if (jtaLookup.getTransactionManager() != null) { + jtaEnabled = true; + } + } + } + + private void lazyInit(KeycloakSession session) { + if (emf == null) { + synchronized (this) { + if (emf == null) { + KeycloakModelUtils.suspendJtaTransaction(session.getKeycloakSessionFactory(), () -> { + logger.debug("Initializing Quarkus JPA connections"); + + Map properties = new HashMap<>(); + + String unitName = "keycloak-default"; + + String schema = getSchema(); + if (schema != null) { + properties.put(JpaUtils.HIBERNATE_DEFAULT_SCHEMA, schema); + } + + MigrationStrategy migrationStrategy = getMigrationStrategy(); + boolean initializeEmpty = config.getBoolean("initializeEmpty", true); + File databaseUpdateFile = getDatabaseUpdateFile(); + + properties.put("hibernate.show_sql", config.getBoolean("showSql", false)); + properties.put("hibernate.format_sql", config.getBoolean("formatSql", true)); + properties.put(AvailableSettings.CONNECTION_HANDLING, + PhysicalConnectionHandlingMode.DELAYED_ACQUISITION_AND_RELEASE_AFTER_STATEMENT); + + properties.put(AvailableSettings.DATASOURCE, getDataSource()); + + Connection connection = getConnection(); + try { + prepareOperationalInfo(connection); + + String driverDialect = detectDialect(connection); + if (driverDialect != null) { + properties.put("hibernate.dialect", driverDialect); + } + + migration(migrationStrategy, initializeEmpty, schema, databaseUpdateFile, connection, session); + + int globalStatsInterval = config.getInt("globalStatsInterval", -1); + if (globalStatsInterval != -1) { + properties.put("hibernate.generate_statistics", true); + } + + logger.trace("Creating EntityManagerFactory"); + logger.tracev("***** create EMF jtaEnabled {0} ", jtaEnabled); + + Collection classLoaders = new ArrayList<>(); + if (properties.containsKey(AvailableSettings.CLASSLOADERS)) { + classLoaders.addAll((Collection) properties.get(AvailableSettings.CLASSLOADERS)); + } + classLoaders.add(getClass().getClassLoader()); + properties.put(AvailableSettings.CLASSLOADERS, classLoaders); + emf = JpaUtils.createEntityManagerFactory(session, unitName, properties, jtaEnabled); + logger.trace("EntityManagerFactory created"); + + if (globalStatsInterval != -1) { + startGlobalStats(session, globalStatsInterval); + } + } finally { + // Close after creating EntityManagerFactory to prevent in-mem databases from closing + if (connection != null) { + try { + connection.close(); + } catch (SQLException e) { + logger.warn("Can't close connection", e); + } + } + } + }); + } + } + } + } + + private File getDatabaseUpdateFile() { + String databaseUpdateFile = config.get("migrationExport", "keycloak-database-update.sql"); + return new File(databaseUpdateFile); + } + + protected void prepareOperationalInfo(Connection connection) { + try { + operationalInfo = new LinkedHashMap<>(); + DatabaseMetaData md = connection.getMetaData(); + operationalInfo.put("databaseUrl", md.getURL()); + operationalInfo.put("databaseUser", md.getUserName()); + operationalInfo.put("databaseProduct", md.getDatabaseProductName() + " " + md.getDatabaseProductVersion()); + operationalInfo.put("databaseDriver", md.getDriverName() + " " + md.getDriverVersion()); + + logger.debugf("Database info: %s", operationalInfo.toString()); + } catch (SQLException e) { + logger.warn("Unable to prepare operational info due database exception: " + e.getMessage()); + } + } + + + protected String detectDialect(Connection connection) { + String driverDialect = config.get("driverDialect"); + if (driverDialect != null && driverDialect.length() > 0) { + return driverDialect; + } else { + try { + String dbProductName = connection.getMetaData().getDatabaseProductName(); + String dbProductVersion = connection.getMetaData().getDatabaseProductVersion(); + + // For MSSQL2014, we may need to fix the autodetected dialect by hibernate + if (dbProductName.equals("Microsoft SQL Server")) { + String topVersionStr = dbProductVersion.split("\\.")[0]; + boolean shouldSet2012Dialect = true; + try { + int topVersion = Integer.parseInt(topVersionStr); + if (topVersion < 12) { + shouldSet2012Dialect = false; + } + } catch (NumberFormatException nfe) { + } + if (shouldSet2012Dialect) { + String sql2012Dialect = "org.hibernate.dialect.SQLServer2012Dialect"; + logger.debugf("Manually override hibernate dialect to %s", sql2012Dialect); + return sql2012Dialect; + } + } + // For Oracle19c, we may need to set dialect explicitly to workaround https://hibernate.atlassian.net/browse/HHH-13184 + if (dbProductName.equals("Oracle") && connection.getMetaData().getDatabaseMajorVersion() > 12) { + logger.debugf("Manually specify dialect for Oracle to org.hibernate.dialect.Oracle12cDialect"); + return "org.hibernate.dialect.Oracle12cDialect"; + } + } catch (SQLException e) { + logger.warnf("Unable to detect hibernate dialect due database exception : %s", e.getMessage()); + } + + return null; + } + } + + protected void startGlobalStats(KeycloakSession session, int globalStatsIntervalSecs) { + logger.debugf("Started Hibernate statistics with the interval %s seconds", globalStatsIntervalSecs); + TimerProvider timer = session.getProvider(TimerProvider.class); + timer.scheduleTask(new HibernateStatsReporter(emf), globalStatsIntervalSecs * 1000, "ReportHibernateGlobalStats"); + } + + void migration(MigrationStrategy strategy, boolean initializeEmpty, String schema, File databaseUpdateFile, Connection connection, KeycloakSession session) { + JpaUpdaterProvider updater = session.getProvider(JpaUpdaterProvider.class); + + JpaUpdaterProvider.Status status = updater.validate(connection, schema); + if (status == JpaUpdaterProvider.Status.VALID) { + logger.debug("Database is up-to-date"); + } else if (status == JpaUpdaterProvider.Status.EMPTY) { + if (initializeEmpty) { + update(connection, schema, session, updater); + } else { + switch (strategy) { + case UPDATE: + update(connection, schema, session, updater); + break; + case MANUAL: + export(connection, schema, databaseUpdateFile, session, updater); + throw new ServerStartupError("Database not initialized, please initialize database with " + databaseUpdateFile.getAbsolutePath(), false); + case VALIDATE: + throw new ServerStartupError("Database not initialized, please enable database initialization", false); + } + } + } else { + switch (strategy) { + case UPDATE: + update(connection, schema, session, updater); + break; + case MANUAL: + export(connection, schema, databaseUpdateFile, session, updater); + throw new ServerStartupError("Database not up-to-date, please migrate database with " + databaseUpdateFile.getAbsolutePath(), false); + case VALIDATE: + throw new ServerStartupError("Database not up-to-date, please enable database migration", false); + } + } + } + + protected void update(Connection connection, String schema, KeycloakSession session, JpaUpdaterProvider updater) { + runJobInTransaction(session.getKeycloakSessionFactory(), (KeycloakSession lockSession) -> { + DBLockManager dbLockManager = new DBLockManager(lockSession); + DBLockProvider dbLock2 = dbLockManager.getDBLock(); + dbLock2.waitForLock(DBLockProvider.Namespace.DATABASE); + try { + updater.update(connection, schema); + } finally { + dbLock2.releaseLock(); + } + }, KeycloakTransactionManager.JTAPolicy.NOT_SUPPORTED); + } + + protected void export(Connection connection, String schema, File databaseUpdateFile, KeycloakSession session, JpaUpdaterProvider updater) { + runJobInTransaction(session.getKeycloakSessionFactory(), (KeycloakSession lockSession) -> { + DBLockManager dbLockManager = new DBLockManager(lockSession); + DBLockProvider dbLock2 = dbLockManager.getDBLock(); + dbLock2.waitForLock(DBLockProvider.Namespace.DATABASE); + try { + updater.export(connection, schema, databaseUpdateFile); + } finally { + dbLock2.releaseLock(); + } + }, KeycloakTransactionManager.JTAPolicy.NOT_SUPPORTED); + } + + @Override + public Connection getConnection() { + try { + DataSource dataSource = getDataSource(); + logger.tracev("CDI DataSource: {0}", dataSource); + return dataSource.getConnection(); + } catch (Exception e) { + throw new RuntimeException("Failed to connect to database", e); + } + } + + @Override + public String getSchema() { + return config.get("schema"); + } + + @Override + public Map getOperationalInfo() { + return operationalInfo; + } + + private MigrationStrategy getMigrationStrategy() { + String migrationStrategy = config.get("migrationStrategy"); + if (migrationStrategy == null) { + // Support 'databaseSchema' for backwards compatibility + migrationStrategy = config.get("databaseSchema"); + } + + if (migrationStrategy != null) { + return MigrationStrategy.valueOf(migrationStrategy.toUpperCase()); + } else { + return MigrationStrategy.UPDATE; + } + } + + private DataSource getDataSource() { + return CDI.current().select(DataSource.class).get(); + } + + private static void runJobInTransaction(KeycloakSessionFactory factory, KeycloakSessionTask task, KeycloakTransactionManager.JTAPolicy jtaPolicy) { + KeycloakSession session = factory.create(); + KeycloakTransactionManager tx = session.getTransactionManager(); + if (jtaPolicy != null) + tx.setJTAPolicy(jtaPolicy); + + try { + tx.begin(); + task.run(session); + + if (tx.isActive()) { + if (tx.getRollbackOnly()) { + tx.rollback(); + } else { + tx.commit(); + } + } + } catch (RuntimeException re) { + if (tx.isActive()) { + tx.rollback(); + } + throw re; + } finally { + session.close(); + } + } + +} diff --git a/quarkus/extensions/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory b/quarkus/extensions/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory new file mode 100644 index 0000000000..630760db47 --- /dev/null +++ b/quarkus/extensions/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory @@ -0,0 +1,18 @@ +# +# Copyright 2020 Red Hat, Inc. and/or its affiliates +# and other contributors as indicated by the @author tags. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +org.keycloak.connections.jpa.QuarkusJpaConnectionProviderFactory diff --git a/quarkus/server/src/main/resources/META-INF/keycloak.properties b/quarkus/server/src/main/resources/META-INF/keycloak.properties index 73241bfd9f..7dddf85a8d 100644 --- a/quarkus/server/src/main/resources/META-INF/keycloak.properties +++ b/quarkus/server/src/main/resources/META-INF/keycloak.properties @@ -9,20 +9,23 @@ theme.cacheThemes = true theme.cacheTemplates = true #theme.dir = ${keycloak.home.dir}/themes +# Datasource +datasource.url = jdbc:h2:mem:test;DB_CLOSE_DELAY=-1 +datasource.driver = org.h2.Driver +datasource.username = sa +datasource.password = keycloak + # SPIs eventsListener.jboss-logging.success-level = debug eventsListener.jboss-logging.error-level = warn -connectionsJpa.default.url = jdbc:h2:mem:test;DB_CLOSE_DELAY=-1 -connectionsJpa.default.driver = org.h2.Driver -connectionsJpa.default.user = sa -connectionsJpa.default.password = keycloak -connectionsJpa.default.initializeEmpty = true -connectionsJpa.default.migrationStrategy = update -connectionsJpa.default.showSql = false -connectionsJpa.default.formatSql = true -connectionsJpa.default.globalStatsInterval = -1 +connectionsJpa.provider = quarkus +connectionsJpa.quarkus.initializeEmpty = true +connectionsJpa.quarkus.migrationStrategy = update +connectionsJpa.quarkus.showSql = false +connectionsJpa.quarkus.formatSql = true +connectionsJpa.quarkus.globalStatsInterval = -1 eventsStore.provider=jpa realm.provider=jpa diff --git a/quarkus/server/src/main/resources/application.properties b/quarkus/server/src/main/resources/application.properties index c2cf524b65..1683c209f6 100644 --- a/quarkus/server/src/main/resources/application.properties +++ b/quarkus/server/src/main/resources/application.properties @@ -2,5 +2,6 @@ quarkus.application.name=Keycloak quarkus.servlet.context-path = / +quarkus.datasource.driver=org.h2.Driver resteasy.disable.html.sanitizer = true \ No newline at end of file