db/flyway.sc (138 lines of code) (raw):
#!/usr/bin/env -S scala-cli shebang -S 3.3
//> using jvm corretto:17
//> using dep com.lihaoyi::ujson:4.1.0
//> using dep org.flywaydb:flyway-core:11.6.0
//> using dep org.flywaydb:flyway-database-postgresql:11.6.0
//> using dep org.postgresql:postgresql:42.7.5
//> using dep software.amazon.awssdk:rds:2.31.20
//> using dep software.amazon.awssdk:secretsmanager:2.31.20
import java.nio.file.Path
import software.amazon.awssdk.services.rds.model.GenerateAuthenticationTokenRequest
import software.amazon.awssdk.services.rds.RdsClient
import org.flywaydb.core.Flyway
import scala.io.StdIn.readLine
import scala.jdk.CollectionConverters._
import scala.util.Try
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient
import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest
type Row = List[String]
type Table = List[Row]
def infoCmd(env: String, flyway: Flyway): Unit = {
val table: Table = (flyway
.info()
.all()
.toList
.map(info =>
List(
info.getVersion().getVersion(),
info.getDescription(),
Try(info.getInstalledOn().toString()).getOrElse(""),
Try(info.getState().toString()).getOrElse("")
)
))
tabulate(List("version", "description", "installed on", "state"), table)
def tabulate(headers: Row, t: Table) = {
val widths = (headers +: t).transpose.map(_.map(_.length).max)
val totalWidth = widths.sum + widths.size * 3 - 1
def pad(cols: List[String]) =
cols.zipWithIndex.map(pair => pair._1.padTo(widths(pair._2), ' '))
println("+" + "-" * totalWidth + "+")
println(pad(headers).mkString("| ", " | ", " |"))
println("+" + "-" * totalWidth + "+")
for (row <- t) {
println(pad(row).mkString("| ", " | ", " |"))
}
println("+" + "-" * totalWidth + "+")
}
}
def migrateCmd(env: String, flyway: Flyway): Unit = {
println()
println("Validating migration schema...")
println()
val pendingMigrations = flyway.info().pending()
println()
if (pendingMigrations.isEmpty) {
println("No migrations needed, exiting...")
} else {
println("Validation succeeded")
println(
s"Ready to run ${pendingMigrations.length} migrations on the $env environment database!"
)
print(s"Are you sure these migrations are ready to run? (y/N) ")
val decision = readLine()
if (decision.trim().toLowerCase().startsWith("y")) {
flyway.migrate()
println("All migrations run, exiting successfully")
} else {
println("No migrations run, exiting as requested")
sys.exit(2)
}
}
}
def localFlyway: Flyway = buildFlyway("postgres")
val location = Path.of(scriptPath).getParent().resolve("migrations").toString()
def buildFlyway(password: String) =
Flyway
.configure()
.dataSource(
"jdbc:postgresql://localhost:5432/newswires",
"postgres",
password
)
.locations(s"filesystem:$location")
.load()
def remoteFlyway(stage: String): Flyway = {
val credentials =
DefaultCredentialsProvider.builder().profileName("editorial-feeds").build()
val secretsManager =
SecretsManagerClient
.builder()
.credentialsProvider(credentials)
.region(Region.EU_WEST_1)
.build()
val matchingSecret = secretsManager
.listSecrets()
.secretList()
.asScala
.find(secret => {
val tags = secret.tags().asScala
secret.name().contains("NewswiresDBNewswiresSecret") && tags.exists(tag =>
tag.key == "App" && tag.value == "newswires"
) && tags.exists(tag => tag.key == "Stage" && tag.value == stage)
})
.getOrElse {
println("No secret matching the expected name or tags!")
sys.exit(1)
}
val getSecretRequest = GetSecretValueRequest
.builder()
.secretId(matchingSecret.arn())
.build()
val response = secretsManager.getSecretValue(getSecretRequest)
val secretData = ujson.read(response.secretString())
val rds = RdsClient.builder()
.credentialsProvider(credentials)
.region(Region.EU_WEST_1)
.build()
val generateTokenRequest = GenerateAuthenticationTokenRequest.builder()
.credentialsProvider(credentials)
.username("postgres")
.port(5432)
.hostname(secretData("host").str)
.build()
val token = rds.utilities().generateAuthenticationToken(generateTokenRequest)
buildFlyway(token)
}
val command = args.lift(0) match {
case Some("info") => infoCmd
case Some("migrate") => migrateCmd
case o =>
val msg = o.fold("No command specified!")(cmd => s"Unknown command $cmd!")
println(s"$msg Try again with one of `info`, `migrate`")
sys.exit(1)
}
val (env, flyway) = args.lift(1).map(_.toLowerCase()) match {
case Some("local") => ("local", localFlyway)
case Some("code") => ("code", remoteFlyway("CODE"))
case Some("prod") => ("prod", remoteFlyway("PROD"))
case o =>
val msg = o.fold("No environment specified!")(env => s"Unknown env $env!")
println(s"$msg Try again with one of `local`, `code` or `prod`")
sys.exit(1)
}
command(env, flyway)