diff --git a/build.gradle b/build.gradle index 54b2cb2a5e5d..c7a78ab6bb52 100644 --- a/build.gradle +++ b/build.gradle @@ -56,6 +56,7 @@ dependencies { implementation("com.google.cloud:google-cloud-logging") implementation("tools.jackson.core:jackson-databind:3.1.1") implementation("commons-lang:commons-lang:2.6") + implementation("org.liquibase:liquibase-core:4.27.0") implementation("com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260102.1") implementation("com.helger:ph-commons:9.5.5") // necessary to add SpotBugs suppression implementation("com.mailjet:mailjet-client:5.2.5") diff --git a/src/main/java/teammates/common/exception/PendingDatabaseMigrationsException.java b/src/main/java/teammates/common/exception/PendingDatabaseMigrationsException.java new file mode 100644 index 000000000000..d53fb9fcbbc1 --- /dev/null +++ b/src/main/java/teammates/common/exception/PendingDatabaseMigrationsException.java @@ -0,0 +1,19 @@ +package teammates.common.exception; + +/** + * Exception thrown when there are unapplied database migrations. + */ +public class PendingDatabaseMigrationsException extends Exception { + + private final String migrationStatus; + + public PendingDatabaseMigrationsException(String message, String migrationStatus) { + super(message); + this.migrationStatus = migrationStatus; + } + + public String getMigrationStatus() { + return migrationStatus; + } + +} diff --git a/src/main/java/teammates/liquibase/LiquibaseStatusChecker.java b/src/main/java/teammates/liquibase/LiquibaseStatusChecker.java new file mode 100644 index 000000000000..59dfc111314c --- /dev/null +++ b/src/main/java/teammates/liquibase/LiquibaseStatusChecker.java @@ -0,0 +1,82 @@ +package teammates.liquibase; + +import java.util.List; +import java.util.Optional; + +import teammates.common.exception.PendingDatabaseMigrationsException; +import teammates.common.util.Config; + +import liquibase.Contexts; +import liquibase.LabelExpression; +import liquibase.Liquibase; +import liquibase.changelog.ChangeSet; +import liquibase.database.Database; +import liquibase.database.DatabaseFactory; +import liquibase.exception.DatabaseException; +import liquibase.exception.LiquibaseException; +import liquibase.resource.ClassLoaderResourceAccessor; +import liquibase.resource.ResourceAccessor; + +/** + * Checks whether the liquibase status is successful. + */ +public final class LiquibaseStatusChecker { + + private static final String CHANGELOG_FILE = "db/changelog/db.changelog-root.xml"; + + private LiquibaseStatusChecker() { + // utility class + } + + /** + * Asserts that liquibase reports successful status. + */ + public static void assertSuccessStatus() throws LiquibaseException, PendingDatabaseMigrationsException { + assertDatabaseUpToDate(); + } + + /** + * Fails fast when Liquibase reports unapplied changesets. + */ + private static void assertDatabaseUpToDate() throws LiquibaseException, PendingDatabaseMigrationsException { + Optional pendingMigrationStatus = getPendingMigrationStatus(); + if (pendingMigrationStatus.isEmpty()) { + return; + } + + throw new PendingDatabaseMigrationsException( + "Database schema is out of date. Apply pending Liquibase migrations before starting the server.", + pendingMigrationStatus.get()); + } + + private static Optional getPendingMigrationStatus() throws LiquibaseException { + List unrunChangeSets = listUnrunChangeSets(); + return unrunChangeSets.isEmpty() + ? Optional.empty() + : Optional.of(formatPendingMigrationStatus(unrunChangeSets)); + } + + private static List listUnrunChangeSets() throws LiquibaseException { + try (ResourceAccessor resourceAccessor = new ClassLoaderResourceAccessor(); + Liquibase liquibase = createLiquibase(resourceAccessor)) { + return liquibase.listUnrunChangeSets(new Contexts(), new LabelExpression()); + } catch (Exception e) { + throw new LiquibaseException("Failed to check Liquibase status", e); + } + } + + private static Liquibase createLiquibase(ResourceAccessor resourceAccessor) throws DatabaseException { + Database database = DatabaseFactory.getInstance().openDatabase( + Config.getDbConnectionUrl(), + Config.POSTGRES_USERNAME, + Config.POSTGRES_PASSWORD, + null, + resourceAccessor); + return new Liquibase(CHANGELOG_FILE, resourceAccessor, database); + } + + private static String formatPendingMigrationStatus(List unrunChangeSets) { + return unrunChangeSets.size() + " pending Liquibase changeset(s)."; + } + +} diff --git a/src/main/java/teammates/main/Application.java b/src/main/java/teammates/main/Application.java index 2929b1e18ceb..37903ea07421 100644 --- a/src/main/java/teammates/main/Application.java +++ b/src/main/java/teammates/main/Application.java @@ -10,6 +10,8 @@ import teammates.common.util.Config; import teammates.common.util.Logger; +import teammates.liquibase.LiquibaseStatusChecker; +import teammates.main.util.DevServerStartupErrorHandler; import teammates.ui.servlets.DevServerLoginServlet; /** @@ -75,12 +77,32 @@ public void lifeCycleStopped(LifeCycle event) { server.setHandler(webapp); server.setStopAtShutdown(true); server.addEventListener(customLifeCycleListener); + webapp.setThrowUnavailableOnStartupException(true); - server.start(); + try { + LiquibaseStatusChecker.assertSuccessStatus(); + } catch (Exception e) { + throw Config.IS_DEV_SERVER ? DevServerStartupErrorHandler.transform(e) : e; + } + + try { + server.start(); + } catch (Exception e) { + stopServer(server); + throw Config.IS_DEV_SERVER ? DevServerStartupErrorHandler.transform(e) : e; + } // By using the server.join() the server thread will join with the current thread. // See https://docs.oracle.com/javase/8/docs/api/java/lang/Thread.html#join-- for more details. server.join(); } + private static void stopServer(Server server) { + try { + server.stop(); + } catch (Exception e) { + log.severe("Failed to stop server after web application startup failure.", e); + } + } + } diff --git a/src/main/java/teammates/main/exception/DevServerStartupException.java b/src/main/java/teammates/main/exception/DevServerStartupException.java new file mode 100644 index 000000000000..4588521488bb --- /dev/null +++ b/src/main/java/teammates/main/exception/DevServerStartupException.java @@ -0,0 +1,13 @@ +package teammates.main.exception; + +/** + * Exception thrown when an error occurs during dev server startup. + */ +public class DevServerStartupException extends RuntimeException { + + public DevServerStartupException(String message) { + // Null cause to avoid printing the stack trace twice. + super(message, null, true, false); + } + +} diff --git a/src/main/java/teammates/main/exception/package-info.java b/src/main/java/teammates/main/exception/package-info.java new file mode 100644 index 000000000000..f3bc5fa41c39 --- /dev/null +++ b/src/main/java/teammates/main/exception/package-info.java @@ -0,0 +1,5 @@ + +/** + * Contains exception classes for the main package. + */ +package teammates.main.exception; diff --git a/src/main/java/teammates/main/util/DevServerStartupErrorHandler.java b/src/main/java/teammates/main/util/DevServerStartupErrorHandler.java new file mode 100644 index 000000000000..29f93a797119 --- /dev/null +++ b/src/main/java/teammates/main/util/DevServerStartupErrorHandler.java @@ -0,0 +1,63 @@ +package teammates.main.util; + +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +import teammates.common.exception.PendingDatabaseMigrationsException; +import teammates.main.exception.DevServerStartupException; + +/** + * Handles startup errors with dev-server-friendly messages where possible. + */ +public final class DevServerStartupErrorHandler { + + private static final String SCHEMA_OUT_OF_DATE_MESSAGE = String.join(System.lineSeparator(), + "", + "============================================================", + "Database schema is not up to date:", + "%s", + "", + "Your local database schema is out of date.", + "Run: ./gradlew liquibaseUpdate", + "Then restart the local server.", + "", + "For more details, see docs/how-to/schema-migration.md", + "============================================================", + ""); + + /** + * A list of builders that can recognize startup errors and provide a dev-server-friendly message. + */ + private static final List>> ERROR_MESSAGE_BUILDERS = List.of( + DevServerStartupErrorHandler::buildPendingDatabaseMigrationsMessage); + + private DevServerStartupErrorHandler() { + // Utility class + } + + /** + * Transforms a recognized startup error into a dev-server-friendly exception. + * Returns the original exception when no builder recognizes it. + */ + public static Exception transform(Exception e) { + return ERROR_MESSAGE_BUILDERS.stream() + .map(builder -> builder.apply(e)) + .flatMap(optional -> optional.stream()) + .findFirst() + .map(message -> new DevServerStartupException(message)) + .orElse(e); + } + + /** + * Builds a dev-server-friendly message for a pending database migrations error. + */ + private static Optional buildPendingDatabaseMigrationsMessage(Throwable error) { + if (!(error instanceof PendingDatabaseMigrationsException)) { + return Optional.empty(); + } + PendingDatabaseMigrationsException pendingMigrationsFailure = (PendingDatabaseMigrationsException) error; + return Optional.of(String.format(SCHEMA_OUT_OF_DATE_MESSAGE, pendingMigrationsFailure.getMigrationStatus())); + } + +} diff --git a/src/main/java/teammates/main/util/package-info.java b/src/main/java/teammates/main/util/package-info.java new file mode 100644 index 000000000000..62600a736e34 --- /dev/null +++ b/src/main/java/teammates/main/util/package-info.java @@ -0,0 +1,4 @@ +/** + * Contains utility classes for the main package. + */ +package teammates.main.util;