bestool 2.2.2

BES Deployment tooling
Documentation
//! Operator commands for captures held on this device.
//!
//! A hold is device-local state: nothing schedules it, nothing expires it, and
//! Canopy plays no part in its lifecycle. It is created by a backup asked to
//! keep its capture, and released here.

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},
};

#[cfg(test)]
mod e2e;

/// Manage captures held on this device as local rollback points.
#[derive(Debug, Clone, Parser)]
pub struct HoldArgs {
	#[command(subcommand)]
	pub action: HoldAction,
}

#[derive(Debug, Clone, Subcommand)]
pub enum HoldAction {
	/// Take a capture and hold it, without touching the repository.
	///
	/// Fetches no credentials, contacts no repository, and reports no run, so it
	/// cannot start a transfer: this is the whole point of it existing beside
	/// `bestool canopy backup --hold --no-upload`, where forgetting the second
	/// flag starts one.
	///
	/// The definition's pre/post hooks run and the method prepares its capture
	/// exactly as it would for an uploading run, so the hold is the same artefact
	/// either way. Release it with `bestool canopy hold drop`.
	Create(CreateArgs),

	/// Tell a backup that is already running to keep its capture.
	///
	/// Takes effect when the run finishes; the transfer in progress is not
	/// interrupted or restarted, so a run that has already spent hours uploading
	/// keeps that work and leaves a local rollback point behind as well.
	///
	/// Only reaches a run hosted by the alertd daemon.
	Keep(KeepArgs),

	/// List the captures held on this device.
	List,

	/// Release a held capture and forget it.
	Drop(DropArgs),

	/// Expose a held capture again where its record says it lives.
	///
	/// A hold's mount is made by the process that took it. The daemon that takes
	/// one runs in its own mount namespace, so the mount neither outlives the
	/// daemon nor is visible to a restore run from a shell. This puts it back.
	Reattach(ReattachArgs),
}

#[derive(Debug, Clone, Parser)]
pub struct CreateArgs {
	/// The backup type to capture, as named by a def in the backups directory.
	#[arg(value_name = "TYPE")]
	pub backup_type: String,

	/// Override the backups definition directory.
	#[arg(long, value_name = "DIR")]
	pub backups_dir: Option<PathBuf>,
}

#[derive(Debug, Clone, Parser)]
pub struct ReattachArgs {
	/// The hold to expose again, as shown by `bestool canopy hold list`.
	pub id: String,
}

#[derive(Debug, Clone, Parser)]
pub struct KeepArgs {
	/// The backup type whose running backup should keep its capture.
	#[arg(long = "type", value_name = "TYPE")]
	pub backup_type: String,
}

#[derive(Debug, Clone, Parser)]
pub struct DropArgs {
	/// The hold to release, as shown by `bestool canopy hold list`.
	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(());
	}

	// Probed once: each call shells out per backend, and two passes could report
	// a different state in the table than in the summary below it.
	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(),
			// To the second: the column is sized for it, and an operator picking a
			// rollback point is choosing between captures hours apart.
			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(())
}

/// A coarse age for a listing: which day or hour it is matters, minutes do not.
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?;
	// Putting the exposure back is not the same as the capture being readable
	// through it, and the operator is here because something already told them it
	// was not. Reporting success on the strength of the remount alone sends them
	// round the same loop with nothing new to go on, so say what is true.
	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?;
	// A detached capture is still there to free, so it takes the same release as
	// a mounted one: forgetting the record instead would leave the capture on the
	// filesystem holding its space with nothing naming it.
	if hold::capture_state(&record).await == hold::CaptureState::Gone {
		// Dropping is what the operator asked for, and the capture is already
		// gone; the record going with it is the outcome either way.
		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(())
}