Backup: restore snapshots into ~/bos-restore-<id>
Add a real restic restore that writes into a new directory instead of $HOME, keep dry-run, confirm in the UI, and exclude container storage.
This commit is contained in:
parent
73589ae36d
commit
11c4ecb3cc
3 changed files with 193 additions and 13 deletions
|
|
@ -24,6 +24,7 @@
|
||||||
has_password: boolean;
|
has_password: boolean;
|
||||||
snapshots: ResticSnapshot[];
|
snapshots: ResticSnapshot[];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
home: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let st = $state<BackupStatus | null>(null);
|
let st = $state<BackupStatus | null>(null);
|
||||||
|
|
@ -31,10 +32,27 @@
|
||||||
let password = $state("");
|
let password = $state("");
|
||||||
let snapshots = $state<ResticSnapshot[]>([]);
|
let snapshots = $state<ResticSnapshot[]>([]);
|
||||||
let selected = $state("latest");
|
let selected = $state("latest");
|
||||||
|
let restoreTarget = $state("");
|
||||||
|
let lastAutoTarget = $state("");
|
||||||
let log = $state<string[]>([]);
|
let log = $state<string[]>([]);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let message = $state("");
|
let message = $state("");
|
||||||
|
|
||||||
|
function defaultTarget(id: string): string {
|
||||||
|
const home = st?.home ?? "";
|
||||||
|
if (!home) return "";
|
||||||
|
return `${home}/bos-restore-${id || "latest"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!st?.home) return;
|
||||||
|
const auto = defaultTarget(selected || "latest");
|
||||||
|
if (restoreTarget === "" || restoreTarget === lastAutoTarget) {
|
||||||
|
if (restoreTarget !== auto) restoreTarget = auto;
|
||||||
|
if (lastAutoTarget !== auto) lastAutoTarget = auto;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
st = await invoke<BackupStatus>("get_backup_config");
|
st = await invoke<BackupStatus>("get_backup_config");
|
||||||
repo = st.repo;
|
repo = st.repo;
|
||||||
|
|
@ -79,6 +97,26 @@
|
||||||
snapshots = [];
|
snapshots = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restore(dryRun: boolean) {
|
||||||
|
const snap = selected || "latest";
|
||||||
|
const target = restoreTarget.trim() || defaultTarget(snap);
|
||||||
|
const home = st?.home ?? "";
|
||||||
|
if (!dryRun && home && (target === home || target === `${home}/`)) {
|
||||||
|
message = "Refusing to restore onto $HOME. Leave the default ~/bos-restore-<id> or pick another folder.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!dryRun) {
|
||||||
|
const ok = confirm(
|
||||||
|
`Restore snapshot ${snap} into ${target}?\n\nFiles go into that directory. Your live home is not overwritten.`,
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
await run(dryRun ? "restic_restore_dry_run" : "restic_restore", {
|
||||||
|
snapshot: snap,
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ViewScaffold title="Backup">
|
<ViewScaffold title="Backup">
|
||||||
|
|
@ -100,18 +138,29 @@
|
||||||
{/if}
|
{/if}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group title="Actions" hint="Backup covers $HOME and skips caches, Trash, Steam, cargo/rustup, node_modules, target, and .git. Restore is dry-run only.">
|
<Group
|
||||||
|
title="Actions"
|
||||||
|
hint="This backs up your @home life — documents, configs, the stuff snapper does not. Snapshots on the Snapshots page are root (@) only. Skips caches, Trash, Steam, containers, cargo/rustup, Flatpak, node_modules, target, and .git."
|
||||||
|
>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button disabled={busy} onclick={() => run("restic_init")}>Init repo</button>
|
<button disabled={busy} onclick={() => run("restic_init")}>Init repo</button>
|
||||||
<button class="primary" disabled={busy} onclick={() => run("restic_backup")}>Backup home</button>
|
<button class="primary" disabled={busy} onclick={() => run("restic_backup")}>Backup home</button>
|
||||||
<button disabled={busy} onclick={listSnaps}>List snapshots</button>
|
<button disabled={busy} onclick={listSnaps}>List snapshots</button>
|
||||||
<button disabled={busy} onclick={() => run("restic_restore_dry_run", { snapshot: selected || "latest" })}>
|
|
||||||
Restore dry-run
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{#if message}<Hint text={message} />{/if}
|
{#if message}<Hint text={message} />{/if}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<Group
|
||||||
|
title="Restore"
|
||||||
|
hint="Writes into a new folder (default ~/bos-restore-<id>). Does not overwrite $HOME. Dry-run previews the same target."
|
||||||
|
>
|
||||||
|
<FileField label="Restore into" bind:value={restoreTarget} mode="folder" placeholder="/home/you/bos-restore-latest" />
|
||||||
|
<div class="btn-row">
|
||||||
|
<button disabled={busy || !st?.restic_installed} onclick={() => restore(true)}>Restore dry-run</button>
|
||||||
|
<button class="primary" disabled={busy || !st?.restic_installed} onclick={() => restore(false)}>Restore</button>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Group title="Snapshots" wide>
|
<Group title="Snapshots" wide>
|
||||||
{#if snapshots.length === 0}
|
{#if snapshots.length === 0}
|
||||||
<EmptyState icon={Archive} title="No snapshots loaded" hint="Init, backup, then list." />
|
<EmptyState icon={Archive} title="No snapshots loaded" hint="Init, backup, then list." />
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@ use super::config;
|
||||||
use super::streaming;
|
use super::streaming;
|
||||||
use super::util::{self, command_exists, fail_output};
|
use super::util::{self, command_exists, fail_output};
|
||||||
|
|
||||||
|
fn home_dir() -> PathBuf {
|
||||||
|
std::env::var("HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("/root"))
|
||||||
|
}
|
||||||
|
|
||||||
fn backup_toml() -> PathBuf {
|
fn backup_toml() -> PathBuf {
|
||||||
util::bos_settings_dir().join("backup.toml")
|
util::bos_settings_dir().join("backup.toml")
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +72,7 @@ pub struct BackupStatus {
|
||||||
has_password: bool,
|
has_password: bool,
|
||||||
snapshots: Vec<ResticSnapshot>,
|
snapshots: Vec<ResticSnapshot>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
home: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
|
|
@ -84,6 +91,7 @@ pub fn get_backup_config() -> BackupStatus {
|
||||||
has_password: s.password.is_some(),
|
has_password: s.password.is_some(),
|
||||||
snapshots: Vec::new(),
|
snapshots: Vec::new(),
|
||||||
error: None,
|
error: None,
|
||||||
|
home: home_dir().to_string_lossy().into_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,6 +169,7 @@ fn exclude_args(home: &str) -> Vec<String> {
|
||||||
".cache",
|
".cache",
|
||||||
".local/share/Trash",
|
".local/share/Trash",
|
||||||
".local/share/Steam",
|
".local/share/Steam",
|
||||||
|
".local/share/containers",
|
||||||
".npm",
|
".npm",
|
||||||
".cargo/registry",
|
".cargo/registry",
|
||||||
".cargo/git",
|
".cargo/git",
|
||||||
|
|
@ -210,8 +219,56 @@ pub async fn restic_backup(app: AppHandle, session_id: String) -> bool {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
/// `~/bos-restore-<id>`. Never `$HOME` itself — restore writes into a new
|
||||||
pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot: String) -> bool {
|
/// directory so a bad snapshot cannot clobber the live home.
|
||||||
|
pub fn default_restore_dir(snapshot: &str) -> PathBuf {
|
||||||
|
home_dir().join(format!("bos-restore-{snapshot}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_abs(path: &Path) -> PathBuf {
|
||||||
|
path.components().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute path, not `$HOME` and not `/`. Empty target means the default.
|
||||||
|
pub fn valid_restore_target(path: &Path) -> bool {
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let s = path.to_string_lossy();
|
||||||
|
if s.is_empty() || s.len() > 512 || s.contains('\n') || s.contains('\0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let normalized = normalize_abs(path);
|
||||||
|
if normalized == PathBuf::from("/") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
normalized != normalize_abs(&home_dir())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_restore_target(snapshot: &str, target: Option<&str>) -> Result<PathBuf, String> {
|
||||||
|
if !valid_snapshot_id(snapshot) {
|
||||||
|
return Err("invalid snapshot id".into());
|
||||||
|
}
|
||||||
|
let dest = match target.map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
Some(t) => PathBuf::from(t),
|
||||||
|
None => default_restore_dir(snapshot),
|
||||||
|
};
|
||||||
|
if !valid_restore_target(&dest) {
|
||||||
|
return Err(
|
||||||
|
"restore target must be an absolute path that is not $HOME (default is ~/bos-restore-<id>)"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_restic_restore(
|
||||||
|
app: AppHandle,
|
||||||
|
session_id: String,
|
||||||
|
snapshot: String,
|
||||||
|
target: Option<String>,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> bool {
|
||||||
let s = match require_ready() {
|
let s = match require_ready() {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -220,14 +277,38 @@ pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let snap = snapshot.trim();
|
let snap = snapshot.trim();
|
||||||
if !valid_snapshot_id(snap) {
|
let dest = match resolve_restore_target(snap, target.as_deref()) {
|
||||||
streaming::emit_line(&app, &session_id, "Error: invalid snapshot id");
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
streaming::emit_line(&app, &session_id, &format!("Error: {e}"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
|
};
|
||||||
|
let dest_s = dest.to_string_lossy().into_owned();
|
||||||
let password = s.password.clone().unwrap_or_default();
|
let password = s.password.clone().unwrap_or_default();
|
||||||
let extra = ["restore", snap, "--target", home.as_str(), "--dry-run"];
|
let mut extra = vec![
|
||||||
let args = restic_args(&s.repo, &extra);
|
"restore".to_string(),
|
||||||
|
snap.to_string(),
|
||||||
|
"--target".into(),
|
||||||
|
dest_s.clone(),
|
||||||
|
];
|
||||||
|
if dry_run {
|
||||||
|
extra.push("--dry-run".into());
|
||||||
|
}
|
||||||
|
streaming::emit_line(
|
||||||
|
&app,
|
||||||
|
&session_id,
|
||||||
|
&format!(
|
||||||
|
"{} {snap} → {dest_s}",
|
||||||
|
if dry_run {
|
||||||
|
"Dry-run restore"
|
||||||
|
} else {
|
||||||
|
"Restoring"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
|
||||||
|
let args = restic_args(&s.repo, &extra_refs);
|
||||||
streaming::run_hardcoded_env(
|
streaming::run_hardcoded_env(
|
||||||
app,
|
app,
|
||||||
session_id,
|
session_id,
|
||||||
|
|
@ -238,6 +319,26 @@ pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn restic_restore_dry_run(
|
||||||
|
app: AppHandle,
|
||||||
|
session_id: String,
|
||||||
|
snapshot: String,
|
||||||
|
target: Option<String>,
|
||||||
|
) -> bool {
|
||||||
|
run_restic_restore(app, session_id, snapshot, target, true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn restic_restore(
|
||||||
|
app: AppHandle,
|
||||||
|
session_id: String,
|
||||||
|
snapshot: String,
|
||||||
|
target: Option<String>,
|
||||||
|
) -> bool {
|
||||||
|
run_restic_restore(app, session_id, snapshot, target, false).await
|
||||||
|
}
|
||||||
|
|
||||||
fn valid_snapshot_id(id: &str) -> bool {
|
fn valid_snapshot_id(id: &str) -> bool {
|
||||||
if id == "latest" {
|
if id == "latest" {
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -355,4 +456,33 @@ mod tests {
|
||||||
assert_eq!(v[0].id, "abc123");
|
assert_eq!(v[0].id, "abc123");
|
||||||
assert_eq!(v[0].paths[0], "/home/a");
|
assert_eq!(v[0].paths[0], "/home/a");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_defaults_to_bos_restore_id_not_home() {
|
||||||
|
let dest = default_restore_dir("a1b2c3d4");
|
||||||
|
let home = home_dir();
|
||||||
|
assert_eq!(dest, home.join("bos-restore-a1b2c3d4"));
|
||||||
|
assert_ne!(dest, home);
|
||||||
|
assert!(valid_restore_target(&dest));
|
||||||
|
assert!(!valid_restore_target(&home));
|
||||||
|
assert!(!valid_restore_target(Path::new("/")));
|
||||||
|
assert!(!valid_restore_target(Path::new("relative/path")));
|
||||||
|
assert!(valid_restore_target(Path::new("/tmp/bos-restore-custom")));
|
||||||
|
let resolved = resolve_restore_target("latest", None).unwrap();
|
||||||
|
assert_eq!(resolved, home.join("bos-restore-latest"));
|
||||||
|
assert!(resolve_restore_target("latest", Some(home.to_str().unwrap())).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exclude_covers_caches_and_containers() {
|
||||||
|
let args = exclude_args("/home/a");
|
||||||
|
let joined = args.join(" ");
|
||||||
|
assert!(joined.contains("/home/a/.cache"));
|
||||||
|
assert!(joined.contains("/home/a/.local/share/Trash"));
|
||||||
|
assert!(joined.contains("/home/a/.local/share/Steam"));
|
||||||
|
assert!(joined.contains("/home/a/.local/share/containers"));
|
||||||
|
assert!(joined.contains("node_modules"));
|
||||||
|
assert!(joined.contains("target"));
|
||||||
|
assert!(joined.contains(".git"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ pub fn run() {
|
||||||
commands::backup::restic_init,
|
commands::backup::restic_init,
|
||||||
commands::backup::restic_backup,
|
commands::backup::restic_backup,
|
||||||
commands::backup::restic_restore_dry_run,
|
commands::backup::restic_restore_dry_run,
|
||||||
|
commands::backup::restic_restore,
|
||||||
commands::backup::list_restic_snapshots,
|
commands::backup::list_restic_snapshots,
|
||||||
commands::optional::get_optional_software,
|
commands::optional::get_optional_software,
|
||||||
commands::optional::enable_flathub,
|
commands::optional::enable_flathub,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue