use crate::ctx::Ctx;
use crate::error::{ErrorCode, MitosisError, Result};
use crate::names::validate_name;
use crate::pg::{check_extensions, psql, start, stop, wait_ready, PgInstall, StartOptions};
use crate::ports::pick_port;
use crate::protocol::{GenKind, TemplateInfo};
use crate::state::{GenerationRow, TemplateRow};
use crate::templates::{
build_dir_name, commit_built, not_found, now, redact_url, resume_if_current, template_info,
BuildGuard,
};
use std::collections::HashSet;
use std::fs::OpenOptions;
use std::io::Write;
use std::net::TcpListener;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
fn invalid(msg: impl Into<String>) -> MitosisError {
MitosisError::new(ErrorCode::InvalidRequest, msg)
}
fn internal(msg: impl Into<String>) -> MitosisError {
MitosisError::new(ErrorCode::Internal, msg)
}
async fn blocking<T, F>(f: F) -> Result<T>
where
F: FnOnce() -> Result<T> + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(f)
.await
.map_err(|e| internal(format!("background task failed: {e}")))?
}
pub async fn upgrade(
ctx: &Ctx,
name: &str,
to: u32,
as_name: Option<&str>,
) -> Result<TemplateInfo> {
validate_name("template", name)?;
if let Some(n) = as_name {
validate_name("template", n)?;
if n == name {
return Err(exists(n));
}
}
let _guard = BuildGuard::take(ctx, name)?;
let _new_guard = as_name.map(|n| BuildGuard::take(ctx, n)).transpose()?;
let (row, cur) = ctx.store().current(name)?.ok_or_else(|| not_found(name))?;
if to <= cur.pg_major {
return Err(invalid(format!(
"template {name} is on Postgres {}; --to must be a newer major",
cur.pg_major
))
.with_hint(format!("pick a major above {}", cur.pg_major)));
}
let target = ctx.install_major(to).await?;
if !target.pg_upgrade().is_file() {
return Err(MitosisError::new(
ErrorCode::NoMatchingPostgres,
format!(
"Postgres {to} in {} has no pg_upgrade",
target.bin_dir.display()
),
)
.with_hint(format!("brew reinstall postgresql@{to}")));
}
let (t, ext) = (target.clone(), cur.extensions.clone());
blocking(move || check_extensions(&t, &ext)).await?;
let old = ctx.install_major(cur.pg_major).await?;
let new_name = as_name.unwrap_or(name);
let (created, expected, gen) = reserve(ctx, &row, new_name, as_name.is_some(), cur.gen)?;
let res = run(ctx, &old, &target, &cur, new_name, gen, expected, created).await;
if let Err(e) = &res {
crate::events::emit_template_failed(ctx, new_name, gen, "upgrade", e);
}
if res.is_err() && created {
let store = ctx.store();
if matches!(store.list_generations(new_name), Ok(g) if g.is_empty()) {
let _ = store.delete_template(new_name);
}
}
res
}
fn exists(name: &str) -> MitosisError {
invalid(format!("template {name} already exists"))
.with_hint("pick another --as name, or upgrade in place without --as")
}
fn reserve(
ctx: &Ctx,
row: &TemplateRow,
new_name: &str,
create: bool,
cur_gen: u32,
) -> Result<(bool, u32, u32)> {
let store = ctx.store();
if !create {
return Ok((false, cur_gen, store.alloc_gen(new_name)?));
}
if store.get_template(new_name)?.is_some() {
return Err(exists(new_name));
}
let mut t = TemplateRow::new(
new_name,
&row.database,
row.source.clone(),
row.migrations.clone(),
row.max_age_secs,
);
t.keep = row.keep;
t.every_mode = row.every_mode;
t.migrate = row.migrate.clone();
t.auto_migrate = row.auto_migrate;
t.on_stale = row.on_stale;
t.mask_path = row.mask_path.clone();
t.mask_salt = row.mask_salt.clone();
t.base_branch = row.base_branch.clone();
t.repo_rel_cwd = row.repo_rel_cwd.clone();
t.repo_rel_migrations = row.repo_rel_migrations.clone();
store.insert_template(&t)?;
match store.alloc_gen(new_name) {
Ok(gen) => Ok((true, 0, gen)),
Err(e) => {
let _ = store.delete_template(new_name);
Err(e)
}
}
}
struct Work {
a: PathBuf,
b: PathBuf,
tmp: PathBuf,
socket_dir: PathBuf,
log: PathBuf,
}
#[allow(clippy::too_many_arguments)]
async fn run(
ctx: &Ctx,
old: &PgInstall,
target: &PgInstall,
cur: &GenerationRow,
name: &str,
gen: u32,
expected: u32,
created: bool,
) -> Result<TemplateInfo> {
let mut ts = now();
let dir = ctx.home.templates_dir();
while dir.join(build_dir_name(name, ts)).exists()
|| dir.join(build_dir_name(name, ts + 1)).exists()
{
ts += 2;
}
let (old_port, _old_hold) = hold_port(ctx, &HashSet::new())?;
let (new_port, _new_hold) = hold_port(ctx, &HashSet::from([old_port]))?;
let work = Work {
a: dir.join(build_dir_name(name, ts)),
b: dir.join(build_dir_name(name, ts + 1)),
tmp: ctx.home.logs_dir().join(format!("upgrade-{name}-{ts}")),
socket_dir: ctx.home.sockets_dir().join(format!("build-{old_port}")),
log: ctx.home.logs_dir().join(format!("template-{name}.log")),
};
std::fs::File::create(&work.log)?;
let ports = (old_port, new_port);
if let Err((step, e)) = steps(ctx, old, target, cur, &work, ports).await {
return Err(fail(ctx, old, target, &work, step, e).await);
}
let cleaned = {
let (a, tmp, s) = (work.a.clone(), work.tmp.clone(), work.socket_dir.clone());
blocking(move || {
remove(&tmp)?;
let _ = std::fs::remove_dir(&s);
remove(&a)
})
.await
};
if let Err(e) = cleaned {
return Err(fail(ctx, old, target, &work, "cleanup", e).await);
}
let row = GenerationRow {
template: name.to_string(),
gen,
kind: GenKind::Upgraded,
dir: work.b.clone(),
pg_major: 0,
bin_dir: target.bin_dir.clone(),
extensions: cur.extensions.clone(),
migrations_hash: cur.migrations_hash.clone(),
built_at: ts,
size_bytes: 0,
from_clone: None,
from_gen: (!created).then_some(cur.gen),
mask_hash: cur.mask_hash.clone(),
};
commit_built(ctx, target, &work.log, &[], row, expected).await?;
if !created {
let store = ctx.store();
let mut t = store.get_template(name)?.ok_or_else(|| not_found(name))?;
resume_if_current(&mut t, gen);
store.update_template_settings(&t)?;
}
template_info(ctx, name)
}
fn hold_port(ctx: &Ctx, skip: &HashSet<u16>) -> Result<(u16, TcpListener)> {
let (leased, reserved) = {
let store = ctx.store();
(store.leased_ports()?, store.reserved_ports()?)
};
let mut skip = skip.clone();
loop {
let port = pick_port(ctx.config().port_range, &leased, &skip, &reserved)?;
match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => return Ok((port, l)),
Err(_) => {
skip.insert(port);
}
}
}
}
type StepResult<T> = std::result::Result<T, (&'static str, MitosisError)>;
fn at(step: &'static str) -> impl FnOnce(MitosisError) -> (&'static str, MitosisError) {
move |e| (step, e)
}
async fn steps(
ctx: &Ctx,
old: &PgInstall,
target: &PgInstall,
cur: &GenerationRow,
work: &Work,
(old_port, new_port): (u16, u16),
) -> StepResult<()> {
{
let _swap = ctx.template_swap.read().await;
let (b, f, d) = (ctx.backend.clone(), cur.dir.clone(), work.a.clone());
blocking(move || b.clone_dir(&f, &d))
.await
.map_err(at("copy"))?;
}
let settings = read_settings(ctx, old, work, old_port)
.await
.map_err(at("read settings"))?;
std::fs::create_dir_all(&work.tmp).map_err(|e| ("pg_upgrade", e.into()))?;
for mode in ["--clone", "--copy"] {
let (t, b, s) = (target.clone(), work.b.clone(), settings.clone());
blocking(move || {
remove(&b)?;
initdb_like(&t, &b, &s)
})
.await
.map_err(at("initdb"))?;
let (o, t, a, b, tmp, sock, groups) = (
old.clone(),
target.clone(),
work.a.clone(),
work.b.clone(),
work.tmp.clone(),
work.socket_dir.clone(),
ctx.build_groups.clone(),
);
let out = blocking(move || {
pg_upgrade(
&o,
&t,
&a,
&b,
&tmp,
&sock,
(old_port, new_port),
mode,
&groups,
)
})
.await
.map_err(at("pg_upgrade"))?;
append_log(&work.log, &out.text);
if out.ok {
return match std::fs::remove_file(work.b.join("postmaster.opts")) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(("pg_upgrade", e.into())),
_ => Ok(()),
};
}
let lower = out.text.to_lowercase();
if mode == "--clone" && lower.contains("clon") && lower.contains("not supported") {
append_log(
&work.log,
"mitosis: file cloning not supported, retrying with --copy",
);
continue;
}
return Err(("pg_upgrade", internal("pg_upgrade failed")));
}
Err(("pg_upgrade", internal("pg_upgrade failed")))
}
#[derive(Clone, Debug, PartialEq)]
struct ClusterSettings {
encoding: String,
collate: String,
ctype: String,
provider: String,
locale: Option<String>,
checksums: bool,
}
const SETTINGS_SQL: &str = "SELECT current_setting('data_checksums'), \
pg_encoding_to_char(d.encoding), row_to_json(d) FROM pg_database d \
WHERE datname = 'template0'";
fn parse_settings(line: &str) -> Result<ClusterSettings> {
let bad = || internal(format!("unexpected cluster settings: {line}"));
let mut parts = line.splitn(3, '|');
let (Some(checksums), Some(encoding), Some(json)) = (parts.next(), parts.next(), parts.next())
else {
return Err(bad());
};
let row: serde_json::Value = serde_json::from_str(json).map_err(|_| bad())?;
let text = |k: &str| row.get(k).and_then(|v| v.as_str()).map(String::from);
Ok(ClusterSettings {
encoding: encoding.to_string(),
collate: text("datcollate").ok_or_else(bad)?,
ctype: text("datctype").ok_or_else(bad)?,
provider: text("datlocprovider").unwrap_or_else(|| "c".into()),
locale: text("datlocale").or_else(|| text("daticulocale")),
checksums: checksums == "on",
})
}
async fn read_settings(
ctx: &Ctx,
old: &PgInstall,
work: &Work,
port: u16,
) -> Result<ClusterSettings> {
let opts = StartOptions {
port,
socket_dir: work.socket_dir.clone(),
listen_tcp: false,
durable: false,
log_file: work.log.clone(),
};
let pg = start(old, &work.a, &opts)?;
let ready = wait_ready(old, &pg, &opts, ctx.config().ready_timeout).await;
let read = match ready {
Ok(()) => {
let (i, s) = (old.clone(), opts.socket_dir.clone());
blocking(move || psql(&i, &s, port, "postgres", SETTINGS_SQL)).await
}
Err(e) => Err(e),
};
let (i, d, t) = (old.clone(), work.a.clone(), ctx.config().stop_timeout);
blocking(move || stop(&i, &d, Some(pg), t)).await?;
parse_settings(read?.lines().next().unwrap_or_default())
}
fn initdb_args(s: &ClusterSettings, target_major: u32) -> Vec<String> {
let mut args: Vec<String> = [
"-U",
"postgres",
"--auth-local=trust",
"--auth-host=scram-sha-256",
]
.map(String::from)
.to_vec();
args.push(format!("--encoding={}", s.encoding));
args.push(format!("--lc-collate={}", s.collate));
args.push(format!("--lc-ctype={}", s.ctype));
match (s.provider.as_str(), &s.locale) {
("i", Some(l)) => {
args.push("--locale-provider=icu".into());
args.push(format!("--icu-locale={l}"));
}
("b", Some(l)) => {
args.push("--locale-provider=builtin".into());
args.push(format!("--builtin-locale={l}"));
}
_ if target_major >= 15 => args.push("--locale-provider=libc".into()),
_ => {}
}
if s.checksums {
args.push("--data-checksums".into());
} else if target_major >= 18 {
args.push("--no-data-checksums".into());
}
args
}
fn initdb_like(install: &PgInstall, data_dir: &Path, s: &ClusterSettings) -> Result<()> {
let out = Command::new(install.bin("initdb"))
.arg("-D")
.arg(data_dir)
.args(initdb_args(s, install.major))
.output()?;
if !out.status.success() {
return Err(internal(format!(
"initdb failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(())
}
struct UpgradeOutput {
ok: bool,
text: String,
}
#[allow(clippy::too_many_arguments)]
fn pg_upgrade(
old: &PgInstall,
target: &PgInstall,
a: &Path,
b: &Path,
tmp: &Path,
socket_dir: &Path,
(old_port, new_port): (u16, u16),
mode: &str,
groups: &Arc<Mutex<HashSet<i32>>>,
) -> Result<UpgradeOutput> {
std::fs::create_dir_all(socket_dir)?;
let child = Command::new(target.pg_upgrade())
.arg("-b")
.arg(&old.bin_dir)
.arg("-B")
.arg(&target.bin_dir)
.arg("-d")
.arg(a)
.arg("-D")
.arg(b)
.args(["-U", "postgres", mode, "--socketdir"])
.arg(socket_dir)
.args(["-p", &old_port.to_string(), "-P", &new_port.to_string()])
.env("LC_ALL", "C")
.current_dir(tmp)
.process_group(0)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| internal(format!("could not run pg_upgrade: {e}")))?;
let pgid = child.id() as i32;
let lock = || groups.lock().unwrap_or_else(|e| e.into_inner());
lock().insert(pgid);
let out = child.wait_with_output();
lock().remove(&pgid);
let out = out.map_err(|e| internal(format!("waiting for pg_upgrade: {e}")))?;
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&out.stderr));
if !out.status.success() {
text.push_str(&format!("pg_upgrade exited with {}\n", out.status));
for root in [b.join("pg_upgrade_output.d"), tmp.to_path_buf()] {
log_files(&root, &mut text);
}
}
Ok(UpgradeOutput {
ok: out.status.success(),
text,
})
}
fn log_files(root: &Path, text: &mut String) {
let Ok(rd) = std::fs::read_dir(root) else {
return;
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
log_files(&path, text);
} else if path.extension().is_some_and(|e| e == "log" || e == "txt") {
let body = std::fs::read(&path).unwrap_or_default();
let body = String::from_utf8_lossy(&body);
let lines: Vec<&str> = body.lines().collect();
text.push_str(&format!("--- {}\n", path.display()));
text.push_str(&lines[lines.len().saturating_sub(40)..].join("\n"));
text.push('\n');
}
}
}
fn redact(text: &str) -> String {
const HASH: &str = "SCRAM-SHA-256$";
let text = redact_url(text);
let mut out = String::with_capacity(text.len());
let mut rest = text.as_str();
while let Some(i) = rest.find(HASH) {
let (head, tail) = rest.split_at(i + HASH.len());
out.push_str(head);
out.push_str("***");
let end = tail
.find(|c: char| c.is_whitespace() || matches!(c, '\'' | '"'))
.unwrap_or(tail.len());
rest = &tail[end..];
}
out.push_str(rest);
out
}
fn append_log(log: &Path, text: &str) {
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(log) {
let _ = writeln!(f, "{}", redact(text));
}
}
fn log_tail(log: &Path, n: usize) -> String {
let bytes = std::fs::read(log).unwrap_or_default();
let text = String::from_utf8_lossy(&bytes);
let lines: Vec<&str> = text.lines().collect();
lines[lines.len().saturating_sub(n)..].join("\n")
}
fn remove(path: &Path) -> Result<()> {
match std::fs::remove_dir_all(path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
_ => Ok(()),
}
}
async fn fail(
ctx: &Ctx,
old: &PgInstall,
target: &PgInstall,
work: &Work,
step: &str,
e: MitosisError,
) -> MitosisError {
append_log(&work.log, &format!("mitosis: {step} failed: {e}"));
let tail = redact(&log_tail(&work.log, 20));
let (o, t, timeout) = (old.clone(), target.clone(), ctx.config().stop_timeout);
let (a, b, tmp, sock) = (
work.a.clone(),
work.b.clone(),
work.tmp.clone(),
work.socket_dir.clone(),
);
let cleanup = blocking(move || {
let mut errors = Vec::new();
for (install, dir) in [(&o, &a), (&t, &b)] {
let res = stop(install, dir, None, timeout).and_then(|()| remove(dir));
if let Err(e) = res {
errors.push(format!("{}: {e}", dir.display()));
}
}
if let Err(e) = remove(&tmp) {
errors.push(format!("{}: {e}", tmp.display()));
}
let _ = std::fs::remove_dir(&sock);
match errors.is_empty() {
true => Ok(()),
false => Err(internal(errors.join("; "))),
}
})
.await;
if let Err(ce) = cleanup {
append_log(&work.log, &format!("mitosis: cleanup failed: {ce}"));
}
MitosisError::new(
ErrorCode::TemplateBuildFailed,
format!("template upgrade failed at {step}\n{tail}"),
)
.with_hint(format!("full log: {}", work.log.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(provider: &str, locale: Option<&str>, checksums: bool) -> ClusterSettings {
ClusterSettings {
encoding: "UTF8".into(),
collate: "en_US.UTF-8".into(),
ctype: "en_US.UTF-8".into(),
provider: provider.into(),
locale: locale.map(String::from),
checksums,
}
}
#[test]
fn parses_settings_across_majors() {
let s = parse_settings(
r#"off|UTF8|{"datcollate":"en_US.UTF-8","datctype":"en_US.UTF-8","datlocprovider":"i","datlocale":"und"}"#,
)
.unwrap();
assert_eq!(s, settings("i", Some("und"), false));
let s = parse_settings(
r#"on|UTF8|{"datcollate":"en_US.UTF-8","datctype":"en_US.UTF-8","datlocprovider":"i","daticulocale":"und"}"#,
)
.unwrap();
assert_eq!(s, settings("i", Some("und"), true));
let s = parse_settings(r#"off|UTF8|{"datcollate":"en_US.UTF-8","datctype":"en_US.UTF-8"}"#)
.unwrap();
assert_eq!(s, settings("c", None, false));
assert!(parse_settings("garbage").is_err());
}
#[test]
fn password_hashes_are_cut_from_logs() {
let hash = format!("SCRAM-SHA-256${}", "4096:abc$def:ghi");
let text = format!("ALTER ROLE app WITH PASSWORD '{hash}'; next");
assert_eq!(
redact(&text),
"ALTER ROLE app WITH PASSWORD 'SCRAM-SHA-256$***'; next"
);
assert_eq!(redact("plain line"), "plain line");
}
#[test]
fn initdb_args_match_the_old_cluster() {
let a = initdb_args(&settings("c", None, false), 18);
assert!(a.contains(&"--no-data-checksums".to_string()));
assert!(a.contains(&"--locale-provider=libc".to_string()));
assert!(a.contains(&"--lc-collate=en_US.UTF-8".to_string()));
let a = initdb_args(&settings("i", Some("und"), true), 17);
assert!(a.contains(&"--data-checksums".to_string()));
assert!(a.contains(&"--icu-locale=und".to_string()));
assert!(!a.iter().any(|x| x == "--no-data-checksums"));
let a = initdb_args(&settings("b", Some("C.UTF-8"), false), 17);
assert!(a.contains(&"--builtin-locale=C.UTF-8".to_string()));
}
#[allow(dead_code)]
fn futures_are_send(ctx: &'static Arc<Ctx>) {
fn send<T: Send>(_: T) {}
send(upgrade(ctx, "x", 18, None));
}
}