use std::{
fs::{create_dir_all, File},
io::{self, copy},
path::{Path, PathBuf},
};
use algae_cli::keys::KeyArgs;
use chrono::Utc;
use clap::Parser;
use miette::{Context as _, IntoDiagnostic as _, Result};
use tracing::{debug, error, warn};
use walkdir::WalkDir;
use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter};
use crate::{
actions::{
tamanu::{
backup::{process_backup, Then},
config::{find_config_dir, load_config, TamanuConfig},
find_package, find_tamanu, TamanuArgs,
},
Context,
},
now_time,
};
#[derive(Debug, Clone, Parser)]
pub struct BackupConfigsArgs {
#[cfg_attr(windows, arg(long, default_value = r"C:\Backup\Config"))]
#[cfg_attr(not(windows), arg(long, default_value = "/opt/tamanu-backup/config"))]
pub write_to: PathBuf,
#[arg(long)]
pub then_copy_to: Option<PathBuf>,
#[arg(long)]
pub keep_days: Option<u16>,
#[arg(long, hide = true)]
pub test_skip_caddy: bool,
#[command(flatten)]
pub key: KeyArgs,
}
fn zip_options() -> SimpleFileOptions {
SimpleFileOptions::default()
.unix_permissions(0o644)
.compression_method(CompressionMethod::Stored)
}
fn add_file_impl(zip: &mut ZipWriter<&mut File>, path: &Path, name: &Path) -> Result<()> {
debug!("trying to store file {path:?} at {name:?}");
let mut file = File::open(path)
.inspect_err(|err| {
if err.kind() == io::ErrorKind::NotFound {
debug!("skipping {path:?} because it doesn't exist");
} else {
warn!("skipping {path:?} because {err}");
}
})
.into_diagnostic()?;
zip.start_file_from_path(name, zip_options())
.into_diagnostic()?;
let bytes = copy(&mut file, zip).into_diagnostic()?;
debug!(?bytes, "zipped file {path:?} at {name:?}");
Ok(())
}
fn add_file(
zip: &mut ZipWriter<&mut File>,
path: impl AsRef<Path>,
name: impl AsRef<Path>,
) -> bool {
let path = path.as_ref();
let name = name.as_ref();
add_file_impl(zip, path, name).is_ok_and(|_| true)
}
fn add_dir(
zip: &mut ZipWriter<&mut File>,
path: impl AsRef<Path>,
at: impl AsRef<Path>,
) -> Result<bool> {
let path = path.as_ref();
let at = at.as_ref();
debug!("trying to store dir {path:?} at {at:?}");
if !path.exists() {
debug!("skipping {path:?} because it doesn't exist");
return Ok(false);
}
let mut success = false;
for entry in WalkDir::new(path).follow_links(true) {
let entry = match entry {
Ok(e) => e,
Err(err) => {
warn!("skipping an entry in {path:?} because {err}");
continue;
}
};
let zip_path = if let Ok(file_path) = entry.path().strip_prefix(path) {
at.join(file_path)
} else {
error!(
"file at {:?} is not within search, this should be impossible, skipping",
entry.path()
);
continue;
};
if entry.file_type().is_dir() {
debug!("creating {zip_path:?} dir entry");
zip.add_directory_from_path(zip_path, zip_options())
.into_diagnostic()
.wrap_err("writing directory entry failed, which is fatal")?;
continue;
} else if !entry.file_type().is_file() {
debug!("skipping {zip_path:?} because it's not a file");
continue;
}
success = add_file(zip, entry.path(), &zip_path);
}
Ok(success)
}
fn make_backup_filename(config: &TamanuConfig) -> PathBuf {
let output_date = now_time(&Utc).format("%Y-%m-%d_%H%M");
let output_name = config
.canonical_host_name
.as_ref()
.and_then(|url| url.host_str())
.unwrap_or("localhost");
format!("{output_date}-{output_name}.config.zip").into()
}
pub async fn run(ctx: Context<TamanuArgs, BackupConfigsArgs>) -> Result<()> {
create_dir_all(&ctx.args_sub.write_to)
.into_diagnostic()
.wrap_err("creating dest dir")?;
let (_, root) = find_tamanu(&ctx.args_top)?;
let kind = find_package(&root);
let config = load_config(&root, Some(kind.package_name()))?;
let output = Path::new(&ctx.args_sub.write_to).join(make_backup_filename(&config));
let mut file = std::fs::File::create_new(&output)
.into_diagnostic()
.wrap_err_with(|| format!("opening file {output:?}"))?;
let mut zip = ZipWriter::new(&mut file);
if !ctx.args_sub.test_skip_caddy {
let mut got_caddy = add_dir(&mut zip, "/etc/caddy", "caddy")?;
if !got_caddy {
got_caddy = add_file(&mut zip, r"C:\Caddy\Caddyfile", "caddy/Caddyfile");
}
if !got_caddy {
got_caddy = add_file(&mut zip, r"C:\Caddy\Caddyfile.txt", "caddy/Caddyfile");
}
if !got_caddy {
error!("could not find a caddy to backup");
}
};
add_dir(&mut zip, "/etc/tamanu", "etc-tamanu")?;
add_file(&mut zip, root.join("pm2.config.cjs"), "pm2.config.cjs");
add_dir(&mut zip, root.join("alerts"), "alerts/version")?;
add_dir(&mut zip, r"C:\Tamanu\alerts", "alerts/global")?;
if let Some(path) = find_config_dir(&root, Some(kind.package_name()), ".") {
add_dir(&mut zip, path, kind.package_name())?;
}
zip.finish()
.into_diagnostic()
.wrap_err("finalising archive")?;
file.sync_all()
.into_diagnostic()
.wrap_err("fsyncing zip file")?;
process_backup(
output,
&ctx.args_sub.write_to,
ctx.args_sub.then_copy_to.as_deref().map(|path| Then {
copy_to: path,
split: None,
}),
ctx.args_sub.keep_days,
".config.zip",
ctx.args_sub.key,
)
.await?;
Ok(())
}