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