bestool/actions/
tamanu.rs

1use std::{
2	ffi::OsString,
3	fmt::Debug,
4	fs,
5	path::{Path, PathBuf},
6	str::FromStr,
7};
8
9use clap::{Parser, Subcommand, ValueEnum};
10use itertools::Itertools;
11use miette::{miette, IntoDiagnostic, Result};
12use node_semver::Version;
13use tracing::{debug, instrument};
14
15use super::Context;
16
17mod roots;
18
19/// Interact with Tamanu.
20#[derive(Debug, Clone, Parser)]
21pub struct TamanuArgs {
22	/// Tamanu root to operate in
23	#[arg(long)]
24	pub root: Option<PathBuf>,
25
26	/// Tamanu subcommand
27	#[command(subcommand)]
28	pub action: Action,
29}
30
31super::subcommands! {
32	[Context<TamanuArgs> => {|ctx: Context<TamanuArgs>| -> Result<(Action, Context<TamanuArgs>)> {
33		Ok((ctx.args_top.action.clone(), ctx.with_sub(())))
34	}}](with_sub)
35
36	#[cfg(feature = "tamanu-alerts")]
37	alerts => Alerts(AlertsArgs),
38	#[cfg(feature = "tamanu-backup")]
39	backup => Backup(BackupArgs),
40	#[cfg(feature = "tamanu-backup-configs")]
41	backup_configs => BackupConfigs(BackupConfigsArgs),
42	#[cfg(feature = "tamanu-config")]
43	config => Config(ConfigArgs),
44	#[cfg(feature = "tamanu-download")]
45	download => Download(DownloadArgs),
46	#[cfg(feature = "tamanu-find")]
47	find => Find(FindArgs),
48	#[cfg(feature = "tamanu-greenmask")]
49	greenmask_config => GreenmaskConfig(GreenmaskConfigArgs),
50	#[cfg(all(windows, feature = "tamanu-upgrade"))]
51	prepare_upgrade => PrepareUpgrade(PrepareUpgradeArgs),
52	#[cfg(feature = "tamanu-psql")]
53	psql => Psql(PsqlArgs),
54	#[cfg(all(windows, feature = "tamanu-upgrade"))]
55	upgrade => Upgrade(UpgradeArgs)
56}
57
58/// What kind of server to interact with.
59#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
60pub enum ApiServerKind {
61	/// Central server
62	#[value(alias("central-server"))]
63	Central,
64
65	/// Facility server
66	#[value(alias("facility-server"))]
67	Facility,
68}
69
70impl ApiServerKind {
71	pub fn package_name(&self) -> &'static str {
72		match self {
73			Self::Central => "central-server",
74			Self::Facility => "facility-server",
75		}
76	}
77}
78
79#[instrument(level = "debug")]
80pub fn find_tamanu(args: &TamanuArgs) -> Result<(Version, PathBuf)> {
81	#[inline]
82	fn inner(args: &TamanuArgs) -> Result<(Version, PathBuf)> {
83		if let Some(root) = &args.root {
84			let version = roots::version_of_root(root)?
85				.ok_or_else(|| miette!("no tamanu found in --root={root:?}"))?;
86			Ok((version, root.canonicalize().into_diagnostic()?))
87		} else {
88			roots::find_versions()?
89				.into_iter()
90				.next()
91				.ok_or_else(|| miette!("no tamanu discovered, use --root"))
92		}
93	}
94
95	inner(args).inspect(|(version, root)| debug!(?root, ?version, "found Tamanu root"))
96}
97
98#[instrument(level = "debug")]
99pub fn find_package(root: impl AsRef<Path> + Debug) -> ApiServerKind {
100	fn inner(root: &Path) -> Result<ApiServerKind> {
101		fs::read_dir(root.join("packages"))
102			.into_diagnostic()?
103			.filter_map_ok(|e| e.file_name().into_string().ok())
104			.process_results(|mut iter| {
105				iter.find_map(|dir_name| ApiServerKind::from_str(&dir_name, false).ok())
106					.ok_or_else(|| miette!("Tamanu servers not found"))
107			})
108			.into_diagnostic()?
109	}
110
111	inner(root.as_ref())
112		.inspect(|kind| debug!(?root, ?kind, "using this Tamanu for config"))
113		.map_err(|err| debug!(?err, "failed to detect package, assuming facility"))
114		.unwrap_or(ApiServerKind::Facility)
115}
116
117#[cfg(windows)]
118pub fn find_existing_version() -> Result<Version> {
119	use miette::WrapErr;
120
121	#[derive(serde::Deserialize, Debug)]
122	struct Process {
123		name: String,
124		pm2_env: Pm2Env,
125	}
126
127	#[derive(serde::Deserialize, Debug)]
128	struct Pm2Env {
129		version: Version,
130	}
131
132	let reader = duct::cmd!("cmd", "/C", "pm2", "jlist")
133		.reader()
134		.into_diagnostic()
135		.wrap_err("failed to run pm2")?;
136	let processes: Vec<Process> = serde_json::from_reader(reader).into_diagnostic()?;
137
138	Ok(processes
139		.into_iter()
140		.find(|p| p.name == "tamanu-api-server" || p.name == "tamanu-http-server")
141		.ok_or_else(|| miette!("there's no live Tamanu running"))?
142		.pm2_env
143		.version)
144}
145
146#[cfg(feature = "tamanu-pg-common")]
147#[instrument(level = "debug")]
148pub fn find_postgres_bin(name: &str) -> Result<OsString> {
149	use std::env;
150
151	#[allow(dead_code)]
152	#[tracing::instrument(level = "debug")]
153	fn find_from_installation(root: &str, name: &str) -> Result<OsString> {
154		let version = fs::read_dir(root)
155			.into_diagnostic()?
156			.filter_map(|res| {
157				res.map(|dir| {
158					dir.file_name()
159						.into_string()
160						.ok()
161						.filter(|name| name.parse::<u32>().is_ok())
162				})
163				.transpose()
164			})
165			// Use `u32::MAX` in case of `Err` so that we always catch IO errors.
166			.max_by_key(|res| {
167				res.as_ref()
168					.cloned()
169					.map(|n| n.parse::<u32>().unwrap())
170					.unwrap_or(u32::MAX)
171			})
172			.ok_or_else(|| miette!("the Postgres root {root} is empty"))?
173			.into_diagnostic()?;
174
175		let exec_file_name = if cfg!(windows) {
176			format!("{name}.exe")
177		} else {
178			format!("{name}")
179		};
180		Ok([root, version.as_str(), "bin", &exec_file_name]
181			.iter()
182			.collect::<PathBuf>()
183			.into())
184	}
185
186	#[allow(dead_code)]
187	fn is_in_path(name: &str) -> Option<PathBuf> {
188		let var = env::var_os("PATH")?;
189
190		// Separate PATH value into paths
191		let paths_iter = env::split_paths(&var);
192
193		// Attempt to read each path as a directory
194		let dirs_iter = paths_iter.filter_map(|path| fs::read_dir(path).ok());
195
196		for dir in dirs_iter {
197			let mut matches_iter = dir
198				.filter_map(|file| file.ok())
199				.filter(|file| file.file_name() == name);
200			if let Some(file) = matches_iter.next() {
201				return Some(file.path());
202			}
203		}
204
205		None
206	}
207
208	// On Windows, find `psql` assuming the standard installation using the installer
209	// because PATH on Windows is not reliable.
210	// See https://github.com/rust-lang/rust/issues/37519
211	#[cfg(windows)]
212	return find_from_installation(r"C:\Program Files\PostgreSQL", name);
213
214	#[cfg(target_os = "linux")]
215	if is_in_path(name).is_some() {
216		return Ok(name.into());
217	} else {
218		// Ubuntu reccomends to use pg_ctlcluster over pg_ctl and doesn't put pg_ctl in PATH.
219		// Still, it should be fine for temporary database.
220		return find_from_installation(r"/usr/lib/postgresql", name);
221	}
222
223	#[cfg(not(any(windows, target_os = "linux")))]
224	return Ok(name.into());
225}
226
227#[cfg(feature = "tamanu-pg-common")]
228#[instrument(level = "debug")]
229pub fn find_postgres_version() -> Result<u8> {
230	Ok(String::from_utf8(
231		duct::cmd!(find_postgres_bin("psql")?, "--version")
232			.stdout_capture()
233			.run()
234			.into_diagnostic()?
235			.stdout,
236	)
237	.into_diagnostic()?
238	.split(|c: char| c.is_whitespace() || c == '.')
239	.find_map(|word| u8::from_str(word).ok())
240	.unwrap_or(12)) // 12 is the lowest version we can encounter
241}