use std::path::PathBuf;
use clap::{Parser, Subcommand};
use jiff::{SpanRound, Timestamp, Unit};
use miette::{Result, bail};
use tracing::{info, warn};
use super::{
super::Context,
backup::{DaemonError, capture_only, hold, request_daemon_hold},
};
#[derive(Debug, Clone, Parser)]
pub struct HoldArgs {
#[command(subcommand)]
pub action: HoldAction,
}
#[derive(Debug, Clone, Subcommand)]
pub enum HoldAction {
Create(CreateArgs),
Keep(KeepArgs),
List,
Drop(DropArgs),
Reattach(ReattachArgs),
}
#[derive(Debug, Clone, Parser)]
pub struct CreateArgs {
#[arg(value_name = "TYPE")]
pub backup_type: String,
#[arg(long, value_name = "DIR")]
pub backups_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Parser)]
pub struct ReattachArgs {
pub id: String,
}
#[derive(Debug, Clone, Parser)]
pub struct KeepArgs {
#[arg(long = "type", value_name = "TYPE")]
pub backup_type: String,
}
#[derive(Debug, Clone, Parser)]
pub struct DropArgs {
pub id: String,
}
pub async fn run(args: HoldArgs, _ctx: Context) -> Result<()> {
match args.action {
HoldAction::Create(args) => {
capture_only(&args.backup_type, args.backups_dir.as_deref()).await
}
HoldAction::Keep(args) => keep(&args.backup_type).await,
HoldAction::List => list().await,
HoldAction::Drop(args) => drop_hold(&args.id).await,
HoldAction::Reattach(args) => reattach(&args.id).await,
}
}
async fn keep(backup_type: &str) -> Result<()> {
match request_daemon_hold(backup_type).await {
Ok(true) => {
info!(
backup_type,
"the running backup will keep its capture; release it with `bestool canopy hold drop`"
);
Ok(())
}
Ok(false) => bail!(
"no backup of type '{backup_type}' is running in the alertd daemon, so there is no \
capture to keep; start one with `bestool canopy backup --type {backup_type} --hold`"
),
Err(DaemonError::Unreachable(err)) => bail!(
"the alertd daemon is not reachable ({err}), so a run in flight can't be told to keep \
its capture; only a daemon-hosted run can be reached this way"
),
Err(DaemonError::Failed(message)) => bail!("asking the daemon to keep the capture failed: {message}"),
}
}
async fn list() -> Result<()> {
let records = hold::list().await?;
if records.is_empty() {
println!("no held captures on this device");
return Ok(());
}
let states = futures::future::join_all(
records
.iter()
.map(hold::capture_state),
)
.await;
let now = Timestamp::now();
println!(
"{:<40} {:<10} {:<21} {:<10} {:<8} CAPTURE",
"ID", "BACKEND", "FROZEN", "HELD FOR", "UPLOADED"
);
for (record, state) in records.iter().zip(&states) {
println!(
"{:<40} {:<10} {:<21} {:<10} {:<8} {}",
record.id,
record.capture.backend(),
record.taken_at.map_or_else(
|| "(no freeze instant)".to_owned(),
|at| at.strftime("%Y-%m-%dT%H:%M:%SZ").to_string(),
),
humanise(now - record.held_at),
if record.uploaded { "yes" } else { "no" },
match state {
hold::CaptureState::Present => "present",
hold::CaptureState::Detached => "DETACHED",
hold::CaptureState::Gone => "MISSING",
},
);
}
let gone = states
.iter()
.filter(|state| **state == hold::CaptureState::Gone)
.count();
let detached = states
.iter()
.filter(|state| **state == hold::CaptureState::Detached)
.count();
if detached > 0 {
warn!(
"{detached} of {} held captures are not mounted; dropping one still frees \
the capture behind it",
records.len()
);
}
if gone > 0 {
warn!(
"{gone} of {} held captures are gone; those holds are not rollback points",
records.len()
);
}
Ok(())
}
fn humanise(span: jiff::Span) -> String {
span.round(
SpanRound::new()
.largest(Unit::Day)
.smallest(Unit::Minute)
.relative(jiff::SpanRelativeTo::days_are_24_hours()),
)
.map(|rounded| {
let days = rounded.get_days();
let hours = rounded.get_hours();
if days > 0 {
format!("{days}d {hours}h")
} else {
format!("{hours}h {}m", rounded.get_minutes())
}
})
.unwrap_or_else(|_| "unknown".to_owned())
}
async fn reattach(id: &str) -> Result<()> {
let record = hold::load(id).await?;
hold::reattach(&record.capture).await?;
match hold::capture_state(&record).await {
hold::CaptureState::Present => {
info!(hold = %id, source = %record.source.display(), "the held capture is readable again");
Ok(())
}
hold::CaptureState::Detached => bail!(
"hold {id} was exposed again at {}, but its capture still cannot be read there; \
the shadow copy or volume behind it is no longer serving the capture",
record.source.display()
),
hold::CaptureState::Gone => bail!(
"the capture behind hold {id} is gone, so exposing it again cannot bring it back; \
drop it with `bestool canopy hold drop {id}`"
),
}
}
async fn drop_hold(id: &str) -> Result<()> {
let record = hold::load(id).await?;
if hold::capture_state(&record).await == hold::CaptureState::Gone {
warn!(
hold = %id,
"the capture was already gone; forgetting the hold (it was not a rollback point)"
);
} else {
hold::release(&record.capture).await?;
info!(hold = %id, backend = record.capture.backend(), "released the held capture");
}
hold::remove_record(id).await?;
Ok(())
}