diff --git a/opensabre-base-dependencies/pom.xml b/opensabre-base-dependencies/pom.xml index 1ddef35..ad3b6f3 100644 --- a/opensabre-base-dependencies/pom.xml +++ b/opensabre-base-dependencies/pom.xml @@ -35,6 +35,7 @@ 2.2.47 8.2.0 + 11.18.0 3.5.17 4.0.0 @@ -128,6 +129,11 @@ ${swagger-annotations.version} + + io.github.opensabre + opensabre-starter-migration + ${revision} + io.github.opensabre opensabre-web @@ -226,6 +232,16 @@ ${mybatis-spring.version} + + org.flywaydb + flyway-core + ${flyway.version} + + + org.flywaydb + flyway-mysql + ${flyway.version} + com.mysql mysql-connector-j diff --git a/opensabre-starter-migration/pom.xml b/opensabre-starter-migration/pom.xml new file mode 100644 index 0000000..eb3a041 --- /dev/null +++ b/opensabre-starter-migration/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + + io.github.opensabre + opensabre-framework + ${revision} + + + opensabre-starter-migration + jar + + opensabre-starter-migration + Explicit, process-isolated Flyway migration runner for OpenSabre applications + + + + + io.github.opensabre + opensabre-base-dependencies + ${revision} + pom + import + + + + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + com.mysql + mysql-connector-j + runtime + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/opensabre-starter-migration/src/main/java/io/github/opensabre/migration/FlywayMigrationRunner.java b/opensabre-starter-migration/src/main/java/io/github/opensabre/migration/FlywayMigrationRunner.java new file mode 100644 index 0000000..fc74034 --- /dev/null +++ b/opensabre-starter-migration/src/main/java/io/github/opensabre/migration/FlywayMigrationRunner.java @@ -0,0 +1,117 @@ +package io.github.opensabre.migration; + +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationInfo; +import org.flywaydb.core.api.output.MigrateResult; + +import java.net.URI; +import java.net.URISyntaxException; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.Map; + +/** + * Runs one application's Flyway migrations as a dedicated process. + * + *

The runner intentionally does not start Spring Boot. Deployments invoke it before the + * corresponding application, using the migration scripts and dependencies already contained in + * that application's immutable image.

+ */ +public final class FlywayMigrationRunner { + + private static final String DEFAULT_LOCATIONS = "classpath:db/migration/mysql"; + + private FlywayMigrationRunner() { + } + + /** + * Validates the configured migration history and migrates the database to the requested target. + * + * @param args unused; configuration is supplied through {@code FLYWAY_*} environment variables + */ + public static void main(String[] args) { + Map environment = System.getenv(); + String url = required(environment, "FLYWAY_URL"); + String user = required(environment, "FLYWAY_USER"); + String password = required(environment, "FLYWAY_PASSWORD"); + String database = required(environment, "FLYWAY_DATABASE"); + String target = required(environment, "FLYWAY_TARGET"); + String locations = environment.getOrDefault("FLYWAY_LOCATIONS", DEFAULT_LOCATIONS); + validateTarget(url, user, password, database); + + var configuration = Flyway.configure() + .dataSource(url, user, password) + .locations(split(locations)) + .schemas(database) + .defaultSchema(database) + .createSchemas(false) + .validateMigrationNaming(true) + .validateOnMigrate(true) + .baselineOnMigrate(false) + .cleanDisabled(true) + .target(target); + + Flyway flyway = configuration.load(); + flyway.validate(); + MigrateResult result = flyway.migrate(); + MigrationInfo current = flyway.info().current(); + String currentVersion = current == null || current.getVersion() == null + ? "none" + : current.getVersion().getVersion(); + System.out.printf("Flyway migration completed: database=%s, migrations=%d, current=%s%n", + result.database, result.migrationsExecuted, currentVersion); + } + + private static String required(Map environment, String name) { + String value = environment.get(name); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing required environment variable: " + name); + } + return value; + } + + /** + * Ensures the JDBC URL and the connected MySQL session both point at the explicitly approved + * database. SQL migrations are separately linted to forbid database-switching statements. + */ + private static void validateTarget(String url, String user, String password, String expectedDatabase) { + String urlDatabase = databaseFromUrl(url); + if (!expectedDatabase.equals(urlDatabase)) { + throw new IllegalArgumentException("FLYWAY_DATABASE does not match JDBC URL database: expected=" + + expectedDatabase + ", actual=" + urlDatabase); + } + + try (var connection = DriverManager.getConnection(url, user, password)) { + String connectedDatabase = connection.getCatalog(); + if (!expectedDatabase.equals(connectedDatabase)) { + throw new IllegalStateException("Connected database does not match FLYWAY_DATABASE: expected=" + + expectedDatabase + ", actual=" + connectedDatabase); + } + } catch (SQLException exception) { + throw new IllegalStateException("Unable to verify Flyway database target", exception); + } + } + + private static String databaseFromUrl(String url) { + if (!url.startsWith("jdbc:mysql:")) { + throw new IllegalArgumentException("Only jdbc:mysql URLs are supported: " + url); + } + try { + String path = new URI(url.substring("jdbc:".length())).getPath(); + if (path == null || path.length() <= 1 || path.indexOf('/', 1) >= 0) { + throw new IllegalArgumentException("JDBC URL must select exactly one database: " + url); + } + return path.substring(1); + } catch (URISyntaxException exception) { + throw new IllegalArgumentException("Invalid JDBC URL: " + url, exception); + } + } + + private static String[] split(String locations) { + return locations.lines() + .flatMap(line -> java.util.Arrays.stream(line.split(","))) + .map(String::trim) + .filter(location -> !location.isEmpty()) + .toArray(String[]::new); + } +} diff --git a/opensabre-starter-migration/src/test/java/io/github/opensabre/migration/FlywayMigrationRunnerTest.java b/opensabre-starter-migration/src/test/java/io/github/opensabre/migration/FlywayMigrationRunnerTest.java new file mode 100644 index 0000000..a2e3ee2 --- /dev/null +++ b/opensabre-starter-migration/src/test/java/io/github/opensabre/migration/FlywayMigrationRunnerTest.java @@ -0,0 +1,48 @@ +package io.github.opensabre.migration; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Verifies strict parsing of deployment-supplied migration configuration. + */ +class FlywayMigrationRunnerTest { + + @Test + void rejectsMissingRequiredConfiguration() throws Exception { + Method required = FlywayMigrationRunner.class.getDeclaredMethod("required", Map.class, String.class); + required.setAccessible(true); + + InvocationTargetException exception = assertThrows(InvocationTargetException.class, + () -> required.invoke(null, Map.of(), "FLYWAY_URL")); + + assertEquals("Missing required environment variable: FLYWAY_URL", exception.getCause().getMessage()); + } + + @Test + void extractsTheSingleDatabaseSelectedByMysqlUrl() throws Exception { + Method databaseFromUrl = FlywayMigrationRunner.class.getDeclaredMethod("databaseFromUrl", String.class); + databaseFromUrl.setAccessible(true); + + assertEquals("flyway_test_auth", + databaseFromUrl.invoke(null, "jdbc:mysql://mysql:3306/flyway_test_auth?useSSL=false")); + } + + @Test + void rejectsMysqlUrlWithoutDatabase() throws Exception { + Method databaseFromUrl = FlywayMigrationRunner.class.getDeclaredMethod("databaseFromUrl", String.class); + databaseFromUrl.setAccessible(true); + + InvocationTargetException exception = assertThrows(InvocationTargetException.class, + () -> databaseFromUrl.invoke(null, "jdbc:mysql://mysql:3306/?useSSL=false")); + + assertEquals("JDBC URL must select exactly one database: jdbc:mysql://mysql:3306/?useSSL=false", + exception.getCause().getMessage()); + } +} diff --git a/pom.xml b/pom.xml index 961cc73..6257361 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,7 @@ opensabre-starter-register opensabre-starter-rpc opensabre-starter-persistence + opensabre-starter-migration opensabre-starter-eda opensabre-starter-governance opensabre-starter-security @@ -68,7 +69,7 @@ 0.9.0 1.18.42 - 1.1.2 + 1.1.3-SNAPSHOT