bestool/actions/
tamanu.rs1use 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#[derive(Debug, Clone, Parser)]
21pub struct TamanuArgs {
22 #[arg(long)]
24 pub root: Option<PathBuf>,
25
26 #[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#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
60pub enum ApiServerKind {
61 #[value(alias("central-server"))]
63 Central,
64
65 #[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 .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 let paths_iter = env::split_paths(&var);
192
193 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 #[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 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)) }