Compare commits
14 Commits
v0.1.1
...
8b7d76017f
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b7d76017f | |||
| a6e3471181 | |||
| 87cc05f33a | |||
| 2784bb678a | |||
| 1baeb9f465 | |||
| 5618cb5efc | |||
| 302009cf59 | |||
| 1face8eea8 | |||
| 48078e5bd3 | |||
| 21e674ffb0 | |||
| 694ae9cc71 | |||
| 8fb4578554 | |||
| 4b1cfadc4d | |||
| b42218a883 |
25
.gitea/workflows/goreleaser.yaml
Normal file
25
.gitea/workflows/goreleaser.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up mise
|
||||
uses: jdx/mise-action@v4
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
args: "release --clean"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
28
.github/workflows/goreleaser.yaml
vendored
Normal file
28
.github/workflows/goreleaser.yaml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up mise
|
||||
uses: jdx/mise-action@v4
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
args: "release --clean"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
target/
|
||||
dist/
|
||||
|
||||
15
.goreleaser.yaml
Normal file
15
.goreleaser.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
version: 2
|
||||
before:
|
||||
hooks:
|
||||
- cargo install --locked cargo-zigbuild
|
||||
builds:
|
||||
- builder: rust
|
||||
targets:
|
||||
- x86_64-unknown-linux-gnu
|
||||
- aarch64-unknown-linux-gnu
|
||||
archives:
|
||||
- formats: ["binary"]
|
||||
|
||||
gitea_urls:
|
||||
api: https://git.shee.sh/api/v1
|
||||
download: https://git.shee.sh
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -413,7 +413,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zfsbackup"
|
||||
version = "0.1.1"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "zfsbackup"
|
||||
version = "0.1.1"
|
||||
version = "0.2.3"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
|
||||
6
mise.toml
Normal file
6
mise.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[tools]
|
||||
rust = "stable"
|
||||
zig = "latest"
|
||||
|
||||
[tasks]
|
||||
build = "cargo build"
|
||||
289
src/job.rs
289
src/job.rs
@@ -5,110 +5,11 @@ use chrono::{Local, NaiveDateTime};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
pub struct JobBuilder {
|
||||
sources: Vec<String>,
|
||||
target: String,
|
||||
source_zfs_command: Vec<String>,
|
||||
target_zfs_command: Vec<String>,
|
||||
dryrun: bool,
|
||||
retain: usize,
|
||||
sender: Option<Sender<BackupEvent>>,
|
||||
}
|
||||
|
||||
fn parse_command(commandstr: &str) -> Vec<String> {
|
||||
commandstr
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl JobBuilder {
|
||||
pub fn new(sources: Vec<String>, target: String) -> Self {
|
||||
JobBuilder {
|
||||
sources,
|
||||
target,
|
||||
source_zfs_command: vec!["zfs".to_string()],
|
||||
target_zfs_command: vec!["zfs".to_string()],
|
||||
dryrun: false,
|
||||
retain: 2,
|
||||
sender: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source_zfs_command(mut self, commandstr: &str) -> Self {
|
||||
let command = parse_command(commandstr);
|
||||
self.source_zfs_command = command;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn target_zfs_command(mut self, commandstr: &str) -> Self {
|
||||
let command = parse_command(commandstr);
|
||||
self.target_zfs_command = command;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn zfs_command(mut self, commandstr: &str) -> Self {
|
||||
let command = parse_command(commandstr);
|
||||
self.source_zfs_command = command.clone();
|
||||
self.target_zfs_command = command;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn dryrun(mut self) -> Self {
|
||||
self.dryrun = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn retain(mut self, retain: usize) -> Self {
|
||||
self.retain = retain;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn sender(mut self, sender: Sender<BackupEvent>) -> Self {
|
||||
self.sender = Some(sender);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<Job, String> {
|
||||
let mut job = Job {
|
||||
datasets: vec![],
|
||||
target: self.target,
|
||||
source_zfs_command: self.source_zfs_command,
|
||||
target_zfs_command: self.target_zfs_command,
|
||||
dryrun: self.dryrun,
|
||||
retain: self.retain,
|
||||
sender: self.sender,
|
||||
};
|
||||
let mut datasets: Vec<String> = vec![];
|
||||
for source in &self.sources {
|
||||
let recurse = source.ends_with("/...");
|
||||
let source = source.trim_end_matches("/...");
|
||||
|
||||
let mut args = vec!["list", "-H", "-o", "name"];
|
||||
if recurse {
|
||||
args.push("-r");
|
||||
}
|
||||
args.push(source);
|
||||
|
||||
let mut cmd = job.get_side_command(JobSide::Source);
|
||||
cmd.extend(args);
|
||||
let output = command::exec_command(&cmd)?;
|
||||
datasets.extend(output.lines().map(str::to_string));
|
||||
}
|
||||
if datasets.is_empty() {
|
||||
return Err(String::from("no matching source datasets found"));
|
||||
}
|
||||
job.datasets = datasets;
|
||||
Ok(job)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Job {
|
||||
datasets: Vec<String>,
|
||||
datasets: HashSet<String>,
|
||||
target: String,
|
||||
source_zfs_command: Vec<String>,
|
||||
target_zfs_command: Vec<String>,
|
||||
dryrun: bool,
|
||||
retain: usize,
|
||||
sender: Option<Sender<BackupEvent>>,
|
||||
}
|
||||
@@ -131,7 +32,6 @@ impl Job {
|
||||
println!("Target: {}", self.target);
|
||||
println!("Source ZFS Command: {:?}", self.source_zfs_command);
|
||||
println!("Target ZFS Command: {:?}", self.target_zfs_command);
|
||||
println!("Dryrun: {}", self.dryrun);
|
||||
}
|
||||
|
||||
fn send_event(&self, event: BackupEvent) {
|
||||
@@ -289,7 +189,7 @@ impl Job {
|
||||
res
|
||||
}
|
||||
|
||||
pub fn run(&self) -> Result<(), String> {
|
||||
pub fn run(&self, execute: bool) -> Result<(), String> {
|
||||
for (index, source) in self.datasets.iter().enumerate() {
|
||||
// Check the source exists
|
||||
let mut cmd = self.get_side_command(JobSide::Source);
|
||||
@@ -320,7 +220,7 @@ impl Job {
|
||||
|
||||
let snapshot = self.create_snapshot(source, JobSide::Source)?;
|
||||
let total = self.estimate(&snapshot, Some(&inc_snapshot)).ok();
|
||||
if self.dryrun {
|
||||
if !execute {
|
||||
self.send_event(BackupEvent::DryrunCompleted(source.clone()));
|
||||
self.delete_snapshot(Snapshot {
|
||||
snapshot: snapshot.clone(),
|
||||
@@ -338,7 +238,7 @@ impl Job {
|
||||
});
|
||||
let snapshot = self.create_snapshot(source, JobSide::Source)?;
|
||||
let total = self.estimate(&snapshot, None).ok();
|
||||
if self.dryrun {
|
||||
if !execute {
|
||||
self.send_event(BackupEvent::DryrunCompleted(source.clone()));
|
||||
self.delete_snapshot(Snapshot {
|
||||
snapshot: snapshot.clone(),
|
||||
@@ -358,3 +258,184 @@ impl Job {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct JobBuilder {
|
||||
sources: Vec<String>,
|
||||
target: String,
|
||||
source_zfs_command: Vec<String>,
|
||||
target_zfs_command: Vec<String>,
|
||||
retain: usize,
|
||||
sender: Option<Sender<BackupEvent>>,
|
||||
}
|
||||
|
||||
/// Parse a command string into a vector of strings, splitting on whitespace.
|
||||
/// Quoting is supported using ' or ".
|
||||
fn parse_command(commandstr: &str) -> Vec<String> {
|
||||
let mut arg = String::new();
|
||||
let mut command: Vec<String> = Vec::new();
|
||||
let mut in_single_quote = false;
|
||||
let mut in_double_quote = false;
|
||||
|
||||
for c in commandstr.chars() {
|
||||
match c {
|
||||
'\'' => {
|
||||
if !in_double_quote {
|
||||
in_single_quote = !in_single_quote;
|
||||
} else {
|
||||
arg.push(c);
|
||||
}
|
||||
}
|
||||
'"' => {
|
||||
if !in_single_quote {
|
||||
in_double_quote = !in_double_quote;
|
||||
} else {
|
||||
arg.push(c);
|
||||
}
|
||||
}
|
||||
' ' => {
|
||||
if in_single_quote || in_double_quote {
|
||||
arg.push(c);
|
||||
} else if !arg.is_empty() {
|
||||
command.push(arg.clone());
|
||||
arg.clear();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
arg.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !arg.is_empty() {
|
||||
command.push(arg);
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
impl JobBuilder {
|
||||
/// Create a new JobBuilder with the given sources and target. The sources should be ZFS
|
||||
/// datasets, optionally ending with "/..." to include all child datasets. The target should be
|
||||
/// a ZFS dataset.
|
||||
pub fn new(sources: Vec<String>, target: String) -> Self {
|
||||
JobBuilder {
|
||||
sources,
|
||||
target,
|
||||
source_zfs_command: vec!["zfs".to_string()],
|
||||
target_zfs_command: vec!["zfs".to_string()],
|
||||
retain: 2,
|
||||
sender: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source_zfs_command(mut self, commandstr: &str) -> Self {
|
||||
let command = parse_command(commandstr);
|
||||
self.source_zfs_command = command;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn target_zfs_command(mut self, commandstr: &str) -> Self {
|
||||
let command = parse_command(commandstr);
|
||||
self.target_zfs_command = command;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn zfs_command(mut self, commandstr: &str) -> Self {
|
||||
let command = parse_command(commandstr);
|
||||
self.source_zfs_command = command.clone();
|
||||
self.target_zfs_command = command;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn retain(mut self, retain: usize) -> Self {
|
||||
self.retain = retain;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn sender(mut self, sender: Sender<BackupEvent>) -> Self {
|
||||
self.sender = Some(sender);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<Job, String> {
|
||||
let mut datasets: HashSet<String> = HashSet::new();
|
||||
for source in &self.sources {
|
||||
let recurse = source.ends_with("/...");
|
||||
let source = source.trim_end_matches("/...");
|
||||
|
||||
let mut args = vec!["list", "-H", "-o", "name"];
|
||||
if recurse {
|
||||
args.push("-r");
|
||||
}
|
||||
args.push(source);
|
||||
|
||||
let mut cmd: Vec<&str> = self.source_zfs_command.iter().map(|s| s.as_str()).collect();
|
||||
cmd.extend(args);
|
||||
let output = command::exec_command(&cmd)?;
|
||||
datasets.extend(output.lines().map(str::to_string));
|
||||
}
|
||||
if datasets.is_empty() {
|
||||
return Err(String::from("no matching source datasets found"));
|
||||
}
|
||||
Ok(Job {
|
||||
datasets: datasets,
|
||||
target: self.target,
|
||||
source_zfs_command: self.source_zfs_command,
|
||||
target_zfs_command: self.target_zfs_command,
|
||||
retain: self.retain,
|
||||
sender: self.sender,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct JobConfig {
|
||||
sources: Vec<String>,
|
||||
target: String,
|
||||
source_zfs_command: Option<String>,
|
||||
target_zfs_command: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_simple() {
|
||||
assert_eq!(parse_command("zfs list"), vec!["zfs", "list"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_quoted() {
|
||||
assert_eq!(
|
||||
parse_command("ssh host sudo zfs"),
|
||||
vec!["ssh", "host", "sudo", "zfs"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_with_quotes() {
|
||||
assert_eq!(
|
||||
parse_command(r#"ssh "my host" sudo zfs"#),
|
||||
vec!["ssh", "my host", "sudo", "zfs"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_with_nested_quotes() {
|
||||
assert_eq!(
|
||||
parse_command(r#"ssh 'my " host' "su'do" zfs"#),
|
||||
vec!["ssh", "my \" host", "su'do", "zfs"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_extra_spaces() {
|
||||
assert_eq!(
|
||||
parse_command(" ssh \" ho st \" sudo zfs "),
|
||||
vec!["ssh", " ho st ", "sudo", "zfs"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_empty() {
|
||||
assert_eq!(parse_command(""), Vec::<String>::new());
|
||||
}
|
||||
}
|
||||
|
||||
15
src/main.rs
15
src/main.rs
@@ -35,9 +35,6 @@ struct Args {
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args = Args::parse();
|
||||
let mut builder = JobBuilder::new(args.datasets, args.target);
|
||||
if args.dry_run {
|
||||
builder = builder.dryrun();
|
||||
}
|
||||
if let Some(cmd) = args.zfs_command {
|
||||
builder = builder.zfs_command(&cmd);
|
||||
}
|
||||
@@ -57,11 +54,15 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
} else {
|
||||
Box::new(log::Progressor::new(rx))
|
||||
};
|
||||
thread::spawn(move || pr.run());
|
||||
|
||||
let handle = thread::spawn(move || pr.run());
|
||||
builder = builder.sender(tx);
|
||||
|
||||
let job = builder.build()?;
|
||||
job.run()?;
|
||||
// Create the job in a block so the sender is dropped before
|
||||
// joining the progress thread, allowing it to exit cleanly.
|
||||
{
|
||||
let job = builder.build()?;
|
||||
job.run(!args.dry_run)?;
|
||||
}
|
||||
handle.join().unwrap();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user