/* * Copyright 2016 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.updater.liquibase; import liquibase.Contexts; import liquibase.Liquibase; import liquibase.changelog.ChangeSet; import liquibase.changelog.RanChangeSet; import liquibase.exception.LiquibaseException; import org.jboss.logging.Logger; import org.keycloak.common.util.reflections.Reflections; import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider; import org.keycloak.connections.jpa.updater.JpaUpdaterProvider; import org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProvider; import org.keycloak.connections.jpa.util.JpaUtils; import org.keycloak.models.KeycloakSession; import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.io.Writer; import java.lang.reflect.Method; import java.sql.Connection; import java.util.List; import java.util.Set; import liquibase.LabelExpression; import liquibase.database.Database; import liquibase.exception.DatabaseException; import liquibase.executor.Executor; import liquibase.executor.ExecutorService; import liquibase.executor.LoggingExecutor; import liquibase.statement.core.CreateDatabaseChangeLogTableStatement; import liquibase.util.StreamUtil; /** * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> */ public class LiquibaseJpaUpdaterProvider implements JpaUpdaterProvider { private static final Logger logger = Logger.getLogger(LiquibaseJpaUpdaterProvider.class); public static final String CHANGELOG = "META-INF/jpa-changelog-master.xml"; private final KeycloakSession session; public LiquibaseJpaUpdaterProvider(KeycloakSession session) { this.session = session; } @Override public void update(Connection connection, String defaultSchema) { update(connection, null, defaultSchema); } @Override public void export(Connection connection, String defaultSchema, File file) { update(connection, file, defaultSchema); } private void update(Connection connection, File file, String defaultSchema) { logger.debug("Starting database update"); // Need ThreadLocal as liquibase doesn't seem to have API to inject custom objects into tasks ThreadLocalSessionContext.setCurrentSession(session); Writer exportWriter = null; try { // Run update with keycloak master changelog first Liquibase liquibase = getLiquibaseForKeycloakUpdate(connection, defaultSchema); if (file != null) { exportWriter = new FileWriter(file); } updateChangeSet(liquibase, liquibase.getChangeLogFile(), exportWriter); // Run update for each custom JpaEntityProvider Set<JpaEntityProvider> jpaProviders = session.getAllProviders(JpaEntityProvider.class); for (JpaEntityProvider jpaProvider : jpaProviders) { String customChangelog = jpaProvider.getChangelogLocation(); if (customChangelog != null) { String factoryId = jpaProvider.getFactoryId(); String changelogTableName = JpaUtils.getCustomChangelogTableName(factoryId); liquibase = getLiquibaseForCustomProviderUpdate(connection, defaultSchema, customChangelog, jpaProvider.getClass().getClassLoader(), changelogTableName); updateChangeSet(liquibase, liquibase.getChangeLogFile(), exportWriter); } } } catch (LiquibaseException | IOException e) { throw new RuntimeException("Failed to update database", e); } finally { ThreadLocalSessionContext.removeCurrentSession(); if (exportWriter != null) { try { exportWriter.close(); } catch (IOException ioe) { // ignore } } } } protected void updateChangeSet(Liquibase liquibase, String changelog, Writer exportWriter) throws LiquibaseException, IOException { List<ChangeSet> changeSets = getLiquibaseUnrunChangeSets(liquibase); if (!changeSets.isEmpty()) { List<RanChangeSet> ranChangeSets = liquibase.getDatabase().getRanChangeSetList(); if (ranChangeSets.isEmpty()) { logger.infov("Initializing database schema. Using changelog {0}", changelog); } else { if (logger.isDebugEnabled()) { logger.debugv("Updating database from {0} to {1}. Using changelog {2}", ranChangeSets.get(ranChangeSets.size() - 1).getId(), changeSets.get(changeSets.size() - 1).getId(), changelog); } else { logger.infov("Updating database. Using changelog {0}", changelog); } } if (exportWriter != null) { if (ranChangeSets.isEmpty()) { outputChangeLogTableCreationScript(liquibase, exportWriter); } liquibase.update((Contexts) null, new LabelExpression(), exportWriter, false); } else { liquibase.update((Contexts) null); } logger.debugv("Completed database update for changelog {0}", changelog); } else { logger.debugv("Database is up to date for changelog {0}", changelog); } // Needs to restart liquibase services to clear ChangeLogHistoryServiceFactory.getInstance(). // See https://issues.jboss.org/browse/KEYCLOAK-3769 for discussion relevant to why reset needs to be here resetLiquibaseServices(liquibase); } private void outputChangeLogTableCreationScript(Liquibase liquibase, final Writer exportWriter) throws DatabaseException { Database database = liquibase.getDatabase(); Executor oldTemplate = ExecutorService.getInstance().getExecutor(database); LoggingExecutor executor = new LoggingExecutor(ExecutorService.getInstance().getExecutor(database), exportWriter, database); ExecutorService.getInstance().setExecutor(database, executor); executor.comment("*********************************************************************"); executor.comment("* Keycloak database creation script - apply this script to empty DB *"); executor.comment("*********************************************************************" + StreamUtil.getLineSeparator()); executor.execute(new CreateDatabaseChangeLogTableStatement()); // DatabaseChangeLogLockTable is created before this code is executed and recreated if it does not exist automatically // in org.keycloak.connections.jpa.updater.liquibase.lock.CustomLockService.init() called indirectly from // KeycloakApplication constructor (search for waitForLock() call). Hence it is not included in the creation script. executor.comment("*********************************************************************" + StreamUtil.getLineSeparator()); ExecutorService.getInstance().setExecutor(database, oldTemplate); } @Override public Status validate(Connection connection, String defaultSchema) { logger.debug("Validating if database is updated"); ThreadLocalSessionContext.setCurrentSession(session); try { // Validate with keycloak master changelog first Liquibase liquibase = getLiquibaseForKeycloakUpdate(connection, defaultSchema); Status status = validateChangeSet(liquibase, liquibase.getChangeLogFile()); if (status != Status.VALID) { return status; } // Validate each custom JpaEntityProvider Set<JpaEntityProvider> jpaProviders = session.getAllProviders(JpaEntityProvider.class); for (JpaEntityProvider jpaProvider : jpaProviders) { String customChangelog = jpaProvider.getChangelogLocation(); if (customChangelog != null) { String factoryId = jpaProvider.getFactoryId(); String changelogTableName = JpaUtils.getCustomChangelogTableName(factoryId); liquibase = getLiquibaseForCustomProviderUpdate(connection, defaultSchema, customChangelog, jpaProvider.getClass().getClassLoader(), changelogTableName); if (validateChangeSet(liquibase, liquibase.getChangeLogFile()) != Status.VALID) { return Status.OUTDATED; } } } } catch (LiquibaseException e) { throw new RuntimeException("Failed to validate database", e); } return Status.VALID; } protected Status validateChangeSet(Liquibase liquibase, String changelog) throws LiquibaseException { final Status result; List<ChangeSet> changeSets = getLiquibaseUnrunChangeSets(liquibase); if (!changeSets.isEmpty()) { if (changeSets.size() == liquibase.getDatabaseChangeLog().getChangeSets().size()) { result = Status.EMPTY; } else { logger.debugf("Validation failed. Database is not up-to-date for changelog %s", changelog); result = Status.OUTDATED; } } else { logger.debugf("Validation passed. Database is up-to-date for changelog %s", changelog); result = Status.VALID; } // Needs to restart liquibase services to clear ChangeLogHistoryServiceFactory.getInstance(). // See https://issues.jboss.org/browse/KEYCLOAK-3769 for discussion relevant to why reset needs to be here resetLiquibaseServices(liquibase); return result; } private void resetLiquibaseServices(Liquibase liquibase) { Method resetServices = Reflections.findDeclaredMethod(Liquibase.class, "resetServices"); Reflections.invokeMethod(true, resetServices, liquibase); } @SuppressWarnings("unchecked") private List<ChangeSet> getLiquibaseUnrunChangeSets(Liquibase liquibase) { // TODO tracked as: https://issues.jboss.org/browse/KEYCLOAK-3730 // TODO: When https://liquibase.jira.com/browse/CORE-2919 is resolved, replace the following two lines with: // List<ChangeSet> changeSets = liquibase.listUnrunChangeSets((Contexts) null, new LabelExpression(), false); Method listUnrunChangeSets = Reflections.findDeclaredMethod(Liquibase.class, "listUnrunChangeSets", Contexts.class, LabelExpression.class, boolean.class); return Reflections.invokeMethod(true, listUnrunChangeSets, List.class, liquibase, (Contexts) null, new LabelExpression(), false); } private Liquibase getLiquibaseForKeycloakUpdate(Connection connection, String defaultSchema) throws LiquibaseException { LiquibaseConnectionProvider liquibaseProvider = session.getProvider(LiquibaseConnectionProvider.class); return liquibaseProvider.getLiquibase(connection, defaultSchema); } private Liquibase getLiquibaseForCustomProviderUpdate(Connection connection, String defaultSchema, String changelogLocation, ClassLoader classloader, String changelogTableName) throws LiquibaseException { LiquibaseConnectionProvider liquibaseProvider = session.getProvider(LiquibaseConnectionProvider.class); return liquibaseProvider.getLiquibaseForCustomUpdate(connection, defaultSchema, changelogLocation, classloader, changelogTableName); } @Override public void close() { } public static String getTable(String table, String defaultSchema) { return defaultSchema != null ? defaultSchema + "." + table : table; } }