use std::{
collections::{BTreeSet, HashSet},
path::{Path, PathBuf},
};
use bestool_tamanu::{ApiServerKind, config::load_config, detect_kind, roots};
use clap::Parser;
use miette::{IntoDiagnostic, Result, bail, miette};
use node_semver::Version;
use crate::actions::Context;
use super::{
TamanuArgs,
artifacts::Platform,
download::{self, DownloadArgs},
};
const REPORTING_SCHEMA: &str = "reporting";
const MIGRATION_DIRS: &[&str] = &[
"packages/database/src/migrations",
"packages/database/dist/migrations",
"packages/database/dist/cjs/migrations",
"packages/database/dist/esm/migrations",
];
#[derive(Debug, Clone, Parser)]
pub struct PreUpgradeArgs {
#[arg(value_name = "VERSION")]
pub version: String,
#[arg(long)]
#[cfg_attr(windows, arg(default_value = "/Tamanu"))]
#[cfg_attr(not(windows), arg(default_value = "."))]
pub into: PathBuf,
#[arg(long)]
pub yes: bool,
#[arg(long)]
pub dry_run: bool,
}
pub async fn run(args: PreUpgradeArgs, ctx: Context) -> Result<()> {
let target = Version::parse(args.version.trim_start_matches('v')).into_diagnostic()?;
let tamanu = ctx.require::<TamanuArgs>();
let installed = roots::find_versions()?;
let current_root = match tamanu.root.clone() {
Some(root) => root,
None => installed
.iter()
.find(|(version, _)| *version < target)
.map(|(_, root)| root.clone())
.ok_or_else(|| miette!("no installed Tamanu older than {target} to upgrade from"))?,
};
let config = load_config(¤t_root, None)?;
let client =
bestool_postgres::pool::connect_one(&config.database_url(), "bestool-tamanu-pre-upgrade")
.await?;
let target_root = match installed.iter().find(|(version, _)| *version == target) {
Some((_, root)) => root.clone(),
None if args.dry_run => bail!("{target} isn't downloaded yet; run without --dry-run to fetch it"),
None => {
let kind = detect_kind(&config, Some(&client)).await;
download::run(
DownloadArgs {
kind: artifact_type(kind).into(),
version: target.to_string(),
into: args.into.clone(),
url_only: false,
no_extract: false,
platform: Platform::Host,
},
ctx.clone(),
)
.await?;
roots::find_versions()?
.into_iter()
.find(|(version, _)| *version == target)
.map(|(_, root)| root)
.ok_or_else(|| miette!("downloaded {target} but can't find its release folder"))?
}
};
let applied = client
.query(r#"SELECT name FROM "SequelizeMeta""#, &[])
.await
.into_diagnostic()?
.iter()
.map(|row| row.get::<_, String>(0))
.collect::<Vec<_>>();
let pending = pending_migrations(&release_migrations(&target_root)?, &applied);
let Some(first) = pending.first() else {
println!("{target} has no migrations this database hasn't run, so the reporting schema stays");
return Ok(());
};
println!("{} migration(s) in {target} to run, starting with {first}", pending.len());
let schema = client
.query_opt(
"SELECT obj_description(oid, 'pg_namespace') FROM pg_namespace WHERE nspname = $1",
&[&REPORTING_SCHEMA],
)
.await
.into_diagnostic()?;
let Some(row) = schema else {
println!("no reporting schema to drop");
return Ok(());
};
let managed = is_managed_stamp(row.get::<_, Option<String>>(0).as_deref());
if !managed && !args.yes {
bail!(
"the reporting schema wasn't applied by alertd and may be hand-built; rerun with --yes \
to drop it anyway"
);
}
let aftermath = format!(
"once {target} has migrated, alertd applies the schema canopy publishes for it; if \
canopy publishes none, reinstall it by hand"
);
if args.dry_run {
println!("would drop the reporting schema; {aftermath}");
return Ok(());
}
client
.batch_execute(&format!("DROP SCHEMA {REPORTING_SCHEMA} CASCADE"))
.await
.into_diagnostic()?;
println!("dropped the reporting schema; {aftermath}");
Ok(())
}
fn artifact_type(kind: ApiServerKind) -> &'static str {
match kind {
ApiServerKind::Central => "central",
ApiServerKind::Facility => "facility",
}
}
fn release_migrations(root: &Path) -> Result<Vec<String>> {
let mut names = Vec::new();
for dir in MIGRATION_DIRS.iter().map(|dir| root.join(dir)) {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries {
names.push(entry.into_diagnostic()?.file_name().to_string_lossy().into_owned());
}
}
if names.is_empty() {
bail!(
"found no migrations in {} (looked in {})",
root.display(),
MIGRATION_DIRS.join(", ")
);
}
Ok(names)
}
fn pending_migrations(release: &[String], applied: &[String]) -> Vec<String> {
let applied = applied
.iter()
.filter_map(|name| migration_stem(name))
.collect::<HashSet<_>>();
release
.iter()
.filter_map(|name| migration_stem(name))
.filter(|stem| !applied.contains(stem))
.map(str::to_owned)
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn migration_stem(name: &str) -> Option<&str> {
let (stem, ext) = name.rsplit_once('.')?;
let (prefix, rest) = stem.split_once('-')?;
let is_script = matches!(ext, "js" | "ts" | "cjs" | "mjs");
let is_timestamped = !prefix.is_empty() && prefix.bytes().all(|b| b.is_ascii_digit());
(is_script && is_timestamped && !rest.is_empty()).then_some(stem)
}
fn is_managed_stamp(comment: Option<&str>) -> bool {
let Some((version, digest)) = comment.and_then(|c| c.split_once(' ')) else {
return false;
};
let Some(b64) = digest.strip_prefix("sha256-") else {
return false;
};
!version.is_empty()
&& !version.contains(char::is_whitespace)
&& b64.len() == 44
&& b64.ends_with('=')
&& b64[..43]
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
}
#[cfg(test)]
mod tests {
use super::*;
fn strings(names: &[&str]) -> Vec<String> {
names.iter().map(|n| n.to_string()).collect()
}
#[test]
fn pending_compares_across_source_and_compiled_extensions() {
let release = strings(&[
"1782783331515-makeFkeyDeferrable.ts",
"1784601600000-addIndexes.ts",
"000_baseline.sql",
"notes.json",
]);
let applied = strings(&["1782783331515-makeFkeyDeferrable.js"]);
assert_eq!(
pending_migrations(&release, &applied),
vec!["1784601600000-addIndexes"]
);
}
#[test]
fn nothing_pending_when_every_migration_has_run() {
let release = strings(&["1782783331515-a.ts", "1784601600000-b.ts"]);
let applied = strings(&["1782783331515-a.js", "1784601600000-b.js", "1700000000000-older.js"]);
assert!(pending_migrations(&release, &applied).is_empty());
}
#[test]
fn pending_is_oldest_first_and_deduplicated_across_folders() {
let release = strings(&["1784601600000-b.js", "1782783331515-a.ts", "1784601600000-b.ts"]);
assert_eq!(
pending_migrations(&release, &[]),
vec!["1782783331515-a", "1784601600000-b"]
);
}
#[test]
fn alertd_stamp_is_managed() {
let digest = format!("sha256-{}=", "A".repeat(43));
assert!(is_managed_stamp(Some(&format!("2.64.2 {digest}"))));
}
#[test]
fn anything_else_is_not_managed() {
assert!(!is_managed_stamp(None));
assert!(!is_managed_stamp(Some("2.64.2")));
assert!(!is_managed_stamp(Some("built by hand")));
assert!(!is_managed_stamp(Some("2.64.2 sha256-short=")));
}
}