Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 95 additions & 10 deletions transact-cli/src/main/java/dev/dbos/transact/cli/MigrateCommand.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package dev.dbos.transact.cli;

import dev.dbos.transact.database.SystemDatabase;
import dev.dbos.transact.migrations.MigrationManager;

import java.io.PrintWriter;
Expand Down Expand Up @@ -29,6 +30,19 @@ public class MigrateCommand implements Callable<Integer> {
"Use LISTEN/NOTIFY on the DBOS system database [default: ${DEFAULT-VALUE}]. Use --no-listen-notify to disable.")
boolean useListenNotify;

@Option(
names = {"--print-migrations"},
paramLabel = "[all|NUMBER]",
description =
"Print the SQL of all migrations ('--print-migrations all') or of migrations from a number onward ('--print-migrations 3') instead of running them")
String printMigrations;

@Option(
names = {"--print-user-role"},
description =
"Print the SQL granting the application role (--app-role) access to DBOS system tables instead of executing it")
boolean printUserRole;

@Mixin DatabaseOptions dbOptions;

@Option(
Expand All @@ -39,9 +53,26 @@ public class MigrateCommand implements Callable<Integer> {

@Spec CommandSpec spec;

static final String[] GRANT_QUERIES = {
"GRANT USAGE ON SCHEMA \"%1$s\" TO \"%2$s\"",
"GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA \"%1$s\" TO \"%2$s\"",
"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA \"%1$s\" TO \"%2$s\"",
"GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA \"%1$s\" TO \"%2$s\"",
"ALTER DEFAULT PRIVILEGES IN SCHEMA \"%1$s\" GRANT ALL ON TABLES TO \"%2$s\"",
"ALTER DEFAULT PRIVILEGES IN SCHEMA \"%1$s\" GRANT ALL ON SEQUENCES TO \"%2$s\"",
"ALTER DEFAULT PRIVILEGES IN SCHEMA \"%1$s\" GRANT EXECUTE ON FUNCTIONS TO \"%2$s\""
};

@Override
public Integer call() throws Exception {
var out = spec.commandLine().getOut();

if (printMigrations != null || printUserRole) {
var exitCode = printSql(out, spec.commandLine().getErr());
out.flush();
return exitCode;
}

out.println("Starting DBOS migrations");
out.format(" System Database: %s\n", dbOptions.url());
out.format(" System Database User: %s\n", dbOptions.user());
Expand All @@ -56,29 +87,83 @@ public Integer call() throws Exception {
return 0;
}

// Stdout stays pure SQL and comments (pipeable to a .sql file); never connects.
int printSql(PrintWriter out, PrintWriter err) {
if (printMigrations != null && printUserRole) {
err.println("--print-user-role cannot be combined with --print-migrations");
return 1;
}
var schema = SystemDatabase.sanitizeSchema(dbOptions.schema());
if (schema.contains("'") || schema.contains("\"")) {
err.println("Schema names containing quotes are not supported");
return 1;
}

if (printUserRole) {
if (appRole == null || appRole.isEmpty()) {
err.println("--print-user-role requires --app-role");
return 1;
}
if (appRole.contains("'") || appRole.contains("\"")) {
err.println("Role names containing quotes are not supported");
return 1;
}
out.format("-- Permissions on DBOS schema %s for role %s%n", schema, appRole);
for (var query : GRANT_QUERIES) {
out.println(query.formatted(schema, appRole) + ";");
}
return 0;
}

var latest = MigrationManager.getMigrations(schema, useListenNotify, false).size();
int start;
if (printMigrations.equals("all")) {
start = 1;
} else {
try {
start = Integer.parseInt(printMigrations);
} catch (NumberFormatException e) {
err.format(
"Invalid --print-migrations value '%s': expected 'all' or a migration number%n",
printMigrations);
return 1;
}
if (start < 1 || start > latest) {
err.format(
"Migration %d does not exist: valid migrations are 1 through %d%n", start, latest);
return 1;
}
}

out.format("-- DBOS system database migrations for %s%n", maskPassword(dbOptions.url()));
out.println(
"-- Contains CREATE/DROP INDEX CONCURRENTLY: run outside a transaction block (e.g. plain psql, not psql --single-transaction).");
out.print(MigrationManager.generateMigrationScript(schema, useListenNotify, start));
return 0;
}

static String maskPassword(String url) {
if (url == null) {
return "the system database";
}
return url.replaceAll("(?i)(password=)[^&]*", "$1***");
}

void grantDBOSSchemaPermissions(PrintWriter out, String schema) throws SQLException {

if (appRole == null || appRole.isEmpty()) {
return;
}
schema = SystemDatabase.sanitizeSchema(schema);

out.format(
"Granting permissions for the %s schema to %s in database %s\n",
schema, appRole, dbOptions.url());

String[] queries = {
"GRANT USAGE ON SCHEMA %s TO %s",
"GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA %s TO %s",
"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA %s TO %s",
"GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA %s TO %s",
"ALTER DEFAULT PRIVILEGES IN SCHEMA %s GRANT ALL ON TABLES TO %s",
"ALTER DEFAULT PRIVILEGES IN SCHEMA %s GRANT ALL ON SEQUENCES TO %s",
"ALTER DEFAULT PRIVILEGES IN SCHEMA %s GRANT EXECUTE ON FUNCTIONS TO %s"
};
try (var conn =
DriverManager.getConnection(dbOptions.url(), dbOptions.user(), dbOptions.password());
var stmt = conn.createStatement()) {
for (var query : queries) {
for (var query : GRANT_QUERIES) {
query = query.formatted(schema, appRole);
stmt.execute(query);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package dev.dbos.transact.cli;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import dev.dbos.transact.Constants;
import dev.dbos.transact.database.SystemDatabase;
import dev.dbos.transact.migrations.MigrationManager;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.Collection;
import java.util.List;
Expand Down Expand Up @@ -105,6 +108,134 @@ public void migrate_custom_schema(String schema) throws Exception {
assertTrue(checkTable(schema, "workflow_status"));
}

@Test
public void migrate_print_migrations_apply_funny_schema() throws Exception {
var schema = "F8nny_sCHem@-n@m3";

var script = runPrint("--schema", schema, "--print-migrations", "all");
assertTrue(script.contains("-- Migration 10 skipped: not applicable on fresh databases"));
assertFalse(script.contains("ADD PRIMARY KEY (message_uuid)"));
assertFalse(script.contains("DO $$"));

// Apply the printed script to a fresh database with psql ON_ERROR_STOP.
var applied = pgContainer.execPsql(script);
assertEquals(0, applied.getExitCode(), applied.getStderr());

assertTrue(checkTable(schema, "dbos_migrations"));
assertTrue(checkTable(schema, "workflow_status"));
assertTrue(checkTable(schema, "notifications"));

var latest = MigrationManager.getMigrations(schema, true, false).size();
assertEquals(latest, currentVersion(schema));

// A real migration run now considers the database up to date.
var cmd = new CommandLine(new DBOSCommand());
cmd.setOut(new PrintWriter(new StringWriter()));
var args =
Stream.of(List.of("migrate", "--schema", schema), pgContainer.options())
.flatMap(Collection::stream)
.toArray(String[]::new);
assertEquals(0, cmd.execute(args));
assertEquals(latest, currentVersion(schema));
}

@Test
public void migrate_print_from_migration() throws Exception {
var schema = Constants.DB_SCHEMA;
var latest = MigrationManager.getMigrations(schema, true, false).size();

// Bring a fresh database to version latest-1 by truncating the full script.
var full = runPrint("--print-migrations", "all");
var marker = "UPDATE \"%s\".dbos_migrations SET version = %d;".formatted(schema, latest - 1);
var idx = full.indexOf(marker);
assertTrue(idx > 0);
var appliedPartial = pgContainer.execPsql(full.substring(0, idx + marker.length()) + "\n");
assertEquals(0, appliedPartial.getExitCode(), appliedPartial.getStderr());
assertEquals(latest - 1, currentVersion(schema));

// The last migration printed alone applies on top of version latest-1.
var single = runPrint("--print-migrations", String.valueOf(latest));
assertFalse(single.contains("CREATE SCHEMA"));
assertFalse(single.contains("DO $$"));
assertFalse(single.contains("INSERT INTO \"%s\".dbos_migrations".formatted(schema)));
var applied = pgContainer.execPsql(single);
assertEquals(0, applied.getExitCode(), applied.getStderr());
assertEquals(latest, currentVersion(schema));
}

@Test
public void migrate_print_user_role_grants_access() throws Exception {
var schema = "F8nny_sCHem@-n@m3";
var role = "my-app-role";

var script = runPrint("--schema", schema, "--print-migrations", "all");
var roleScript = runPrint("--schema", schema, "--print-user-role", "--app-role", role);
assertTrue(
roleScript.contains("GRANT USAGE ON SCHEMA \"%s\" TO \"%s\";".formatted(schema, role)));
for (var line : roleScript.split("\n")) {
assertTrue(
line.startsWith("--") || line.startsWith("GRANT") || line.startsWith("ALTER"),
"unexpected output: " + line);
}

try (var conn = pgContainer.connection();
var stmt = conn.createStatement()) {
stmt.execute("DROP ROLE IF EXISTS \"%s\"".formatted(role));
stmt.execute("CREATE ROLE \"%s\" LOGIN PASSWORD 'app_role_pwd'".formatted(role));
}
try {
for (var s : List.of(script, roleScript)) {
var applied = pgContainer.execPsql(s);
assertEquals(0, applied.getExitCode(), applied.getStderr());
}

// The app role can query the DBOS schema.
var latest = MigrationManager.getMigrations(schema, true, false).size();
try (var conn = DriverManager.getConnection(pgContainer.jdbcUrl(), role, "app_role_pwd");
var stmt = conn.createStatement();
var rs =
stmt.executeQuery("SELECT version FROM \"%s\".dbos_migrations".formatted(schema))) {
assertTrue(rs.next());
assertEquals(latest, rs.getInt(1));
}
} finally {
try (var conn = pgContainer.connection();
var stmt = conn.createStatement()) {
stmt.execute("DROP OWNED BY \"%s\"".formatted(role));
stmt.execute("DROP ROLE \"%s\"".formatted(role));
}
}
}

String runPrint(String... printArgs) {
var cmd = new CommandLine(new DBOSCommand());
var sw = new StringWriter();
var ew = new StringWriter();
cmd.setOut(new PrintWriter(sw));
cmd.setErr(new PrintWriter(ew));

var args =
Stream.of(List.of("migrate"), List.of(printArgs), pgContainer.options())
.flatMap(Collection::stream)
.toArray(String[]::new);

assertEquals(0, cmd.execute(args), ew.toString());
assertEquals("", ew.toString());
return sw.toString();
}

int currentVersion(String schema) throws SQLException {
try (var conn = pgContainer.connection();
var stmt = conn.createStatement();
var rs =
stmt.executeQuery("SELECT version FROM \"%s\".dbos_migrations".formatted(schema))) {
assertTrue(rs.next());
var version = rs.getInt(1);
assertFalse(rs.next());
return version;
}
}

boolean checkTable(String schema, String table) throws SQLException {
var sql =
"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = ? AND table_name = ?)";
Expand Down
Loading