use std::path::{Path, PathBuf};
use bestool_canopy::registration;
use bestool_tamanu::server_info::{
standard_device_key_path, standard_server_id_path, standard_tags_path,
};
use clap::Parser;
use miette::{IntoDiagnostic as _, Result, WrapErr as _};
use tracing::warn;
use crate::actions::Context;
#[derive(Debug, Clone, Parser)]
pub struct UnregisterArgs {
#[arg(long, value_name = "DIR")]
pub config: Option<PathBuf>,
#[arg(long, short = 'y')]
pub yes: bool,
}
pub async fn run(args: UnregisterArgs, _ctx: Context) -> Result<()> {
let UnregisterArgs { config, yes } = args;
let dir = config.unwrap_or_else(registration::default_dir);
let tags_path = dir.join("tags.json");
super::ensure_writable_or_reexec(&dir)?;
if !yes && !confirm(&dir, &tags_path)? {
println!("Aborted; nothing was removed.");
return Ok(());
}
let mut removed = Vec::new();
if registration::delete_in(&dir)
.await
.wrap_err("removing canopy registration")?
{
removed.push(format!("canopy registration ({})", dir.display()));
}
for path in [
standard_device_key_path(),
standard_server_id_path(),
tags_path,
standard_tags_path(),
] {
if remove_file(&path)? {
removed.push(path.display().to_string());
}
}
match delete_db_rows().await {
DbOutcome::Deleted(n) if n > 0 => {
removed.push(format!("{n} local_system_facts row(s) (deviceKey/metaServerId)"))
}
DbOutcome::Deleted(_) => {}
DbOutcome::Skipped(why) => {
warn!("{why}; leaving any deviceKey/metaServerId DB rows in place")
}
}
if removed.is_empty() {
println!("No canopy enrolment found; nothing to remove.");
} else {
println!("Removed:");
for item in &removed {
println!(" {item}");
}
}
super::restart_daemon_for_registration_change().await;
Ok(())
}
fn confirm(dir: &Path, tags_path: &Path) -> Result<bool> {
use std::io::Write as _;
println!("This erases this host's canopy enrolment from:");
println!(" canopy registration ({})", dir.display());
println!(" {}", standard_device_key_path().display());
println!(" {}", standard_server_id_path().display());
println!(" {} (cached tags)", tags_path.display());
println!(" {} (legacy cached tags)", standard_tags_path().display());
println!(" deviceKey/metaServerId rows in the Tamanu database (if reachable)");
print!("Proceed? [y/N] ");
std::io::stdout().flush().into_diagnostic()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line).into_diagnostic()?;
Ok(matches!(
line.trim().to_ascii_lowercase().as_str(),
"y" | "yes"
))
}
fn remove_file(path: &Path) -> Result<bool> {
match std::fs::remove_file(path) {
Ok(()) => Ok(true),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(err)
.into_diagnostic()
.wrap_err_with(|| format!("removing {}", path.display())),
}
}
enum DbOutcome {
Deleted(u64),
Skipped(String),
}
async fn delete_db_rows() -> DbOutcome {
let url = match resolve_database_url().await {
Ok(url) => url,
Err(why) => return DbOutcome::Skipped(why),
};
let client = match bestool_postgres::pool::connect_one(&url, "bestool-canopy-unregister").await {
Ok(client) => client,
Err(err) => {
return DbOutcome::Skipped(format!("could not connect to the Tamanu database: {err}"));
}
};
match client
.execute(
"DELETE FROM local_system_facts WHERE key IN ('deviceKey', 'metaServerId')",
&[],
)
.await
{
Ok(n) => DbOutcome::Deleted(n),
Err(err) => DbOutcome::Skipped(format!("could not delete the DB rows: {err}")),
}
}
async fn resolve_database_url() -> Result<String, String> {
use bestool_tamanu::config::{database_url_override, load_config};
if let Some(url) = database_url_override() {
return Ok(url);
}
match bestool_tamanu::try_find_tamanu(None).await {
Ok(Some((_, root))) => match load_config(&root, None) {
Ok(config) => Ok(config.database_url()),
Err(err) => Err(format!("could not load Tamanu config: {err}")),
},
Ok(None) => Err("no Tamanu install found and TAMANU_DATABASE_URL not set".into()),
Err(err) => Err(format!("could not locate Tamanu: {err}")),
}
}