use crate::cli::SeedArgs;
use crate::util::{Result, repo_root, usage_error};
use std::env;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const DEFAULT_AUTH_DATABASE_URL: &str =
"postgres://executesoft:executesoft@localhost:5432/core_auth?sslmode=disable";
const DEFAULT_LOCAL_POSTGRES_CONTAINER: &str = "executesoft-local-dev-postgres-1";
enum SeedRunner {
LocalPsql,
DockerPsql {
container: String,
user: String,
database: String,
},
}
pub(crate) fn run_seed(args: SeedArgs) -> Result<()> {
let migration_dir = migration_dir(&args)?;
let seed_dir = seed_dir(&args)?;
let database_url = args
.database_url
.or_else(|| env::var("DATABASE_URL").ok())
.unwrap_or_else(|| DEFAULT_AUTH_DATABASE_URL.to_string());
let migration_files = sql_files(&migration_dir)?;
let files = seed_files(&seed_dir)?;
if files.is_empty() {
return usage_error(format!("no seed SQL files found in {}", seed_dir.display()));
}
println!("Applying local seeds:");
println!(" database: {}", redact_database_url(&database_url));
println!(" migration dir: {}", migration_dir.display());
println!(" seed dir: {}", seed_dir.display());
let runner = seed_runner(&database_url);
for file in migration_files {
apply_sql_file(&runner, &database_url, "migration", &file)?;
}
for file in files {
apply_sql_file(&runner, &database_url, "seed", &file)?;
}
println!("Local seeds applied.");
Ok(())
}
fn migration_dir(args: &SeedArgs) -> Result<PathBuf> {
match args.service.as_str() {
"auth" => Ok(repo_root().join("services/core/auth/migrations")),
service => usage_error(format!(
"unsupported seed service `{service}`; pass --seed-dir for a custom seed directory"
)),
}
}
fn seed_runner(database_url: &str) -> SeedRunner {
if Command::new("psql")
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok()
{
return SeedRunner::LocalPsql;
}
let target = parse_database_target(database_url);
let container = env::var("SEED_DB_CONTAINER")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_LOCAL_POSTGRES_CONTAINER.to_string());
println!(" psql: local binary not found, using docker container `{container}`");
SeedRunner::DockerPsql {
container,
user: target.user,
database: target.database,
}
}
fn seed_dir(args: &SeedArgs) -> Result<PathBuf> {
if let Some(seed_dir) = &args.seed_dir {
return Ok(path_from_repo(seed_dir));
}
match args.service.as_str() {
"auth" => Ok(repo_root().join("services/core/auth/migrations/seed")),
service => usage_error(format!(
"unsupported seed service `{service}`; pass --seed-dir for a custom seed directory"
)),
}
}
fn path_from_repo(value: &str) -> PathBuf {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else {
repo_root().join(path)
}
}
fn seed_files(dir: &Path) -> Result<Vec<PathBuf>> {
sql_files(dir)
}
fn sql_files(dir: &Path) -> Result<Vec<PathBuf>> {
if !dir.exists() {
return usage_error(format!("SQL directory does not exist: {}", dir.display()));
}
let mut files = Vec::new();
for entry in fs::read_dir(dir)? {
let path = entry?.path();
if path.is_file() && path.extension().is_some_and(|extension| extension == "sql") {
files.push(path);
}
}
files.sort();
Ok(files)
}
fn apply_sql_file(runner: &SeedRunner, database_url: &str, label: &str, file: &Path) -> Result<()> {
println!("Applying {label}: {}", file.display());
match runner {
SeedRunner::LocalPsql => apply_sql_file_with_local_psql(database_url, file),
SeedRunner::DockerPsql {
container,
user,
database,
} => apply_sql_file_with_docker_psql(container, user, database, file),
}
}
fn apply_sql_file_with_local_psql(database_url: &str, file: &Path) -> Result<()> {
let status = Command::new("psql")
.args(["-v", "ON_ERROR_STOP=1", database_url, "-f"])
.arg(file)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.map_err(|error| {
format!(
"failed to start psql: {error}. Install psql or pass a working local DATABASE_URL."
)
})?;
if status.success() {
Ok(())
} else {
Err(format!("psql failed for {} with {status}", file.display()).into())
}
}
fn apply_sql_file_with_docker_psql(
container: &str,
user: &str,
database: &str,
file: &Path,
) -> Result<()> {
let sql = fs::read_to_string(file)?;
let mut child = Command::new("docker")
.args([
"exec",
"-i",
container,
"psql",
"-v",
"ON_ERROR_STOP=1",
"-U",
user,
"-d",
database,
])
.stdin(Stdio::piped())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.map_err(|error| {
format!(
"failed to start docker psql in container `{container}`: {error}. Start local dependencies with `exe dev up`."
)
})?;
child
.stdin
.as_mut()
.ok_or("docker psql stdin was not available")?
.write_all(sql.as_bytes())?;
let status = child.wait()?;
if status.success() {
Ok(())
} else {
Err(format!("docker psql failed for {} with {status}", file.display()).into())
}
}
struct DatabaseTarget {
user: String,
database: String,
}
fn parse_database_target(database_url: &str) -> DatabaseTarget {
let default = DatabaseTarget {
user: "executesoft".to_string(),
database: "core_auth".to_string(),
};
let Some((_scheme, rest)) = database_url.split_once("://") else {
return default;
};
let Some((auth_and_host, path_and_query)) = rest.rsplit_once('/') else {
return default;
};
let database = path_and_query
.split_once('?')
.map(|(database, _query)| database)
.unwrap_or(path_and_query)
.trim();
let user = auth_and_host
.split_once('@')
.map(|(credentials, _host)| credentials)
.and_then(|credentials| credentials.split_once(':').map(|(user, _password)| user))
.unwrap_or("executesoft")
.trim();
DatabaseTarget {
user: if user.is_empty() {
default.user
} else {
user.to_string()
},
database: if database.is_empty() {
default.database
} else {
database.to_string()
},
}
}
fn redact_database_url(database_url: &str) -> String {
let Some((scheme, rest)) = database_url.split_once("://") else {
return database_url.to_string();
};
let Some((credentials, host_and_path)) = rest.split_once('@') else {
return database_url.to_string();
};
let Some((user, _password)) = credentials.split_once(':') else {
return database_url.to_string();
};
format!("{scheme}://{user}:<redacted>@{host_and_path}")
}
#[cfg(test)]
mod tests {
use super::{parse_database_target, redact_database_url};
#[test]
fn redacts_database_password() {
assert_eq!(
redact_database_url("postgres://executesoft:secret@localhost:5432/core_auth"),
"postgres://executesoft:<redacted>@localhost:5432/core_auth"
);
}
#[test]
fn parses_database_target_from_url() {
let target = parse_database_target(
"postgres://executesoft:secret@localhost:5432/core_auth?sslmode=disable",
);
assert_eq!(target.user, "executesoft");
assert_eq!(target.database, "core_auth");
}
}