#!/usr/bin/perl

# this software is licensed for use under the Free Software Foundation's GPL v3.0 license, as retrieved
# from http://www.gnu.org/licenses/gpl-3.0.html on 2014-11-17.  A copy should also be available in this
# project's Git repository at https://github.com/shatteredsilicon/backoid/blob/master/LICENSE.

$::VERSION = '1.0';

use strict;
use warnings;
use Config::IniFiles; # read samba-style conf file
use Getopt::Long qw(:config auto_version auto_help);
use Pod::Usage;
use Time::Local;
use Sys::Hostname;
use Capture::Tiny ':all';
use File::Spec::Functions 'catfile';
use File::Basename;
use File::Path qw(make_path rmtree);
use Digest::SHA qw(sha256_hex);
use POSIX qw(ceil);

my $ps = 'ps';
my $pv = 'pv';
my $zfs = 'zfs';
my $tar = 'tar';
my $rclone = 'rclone';
my $mbuffer = 'mbuffer';

my $DEFAULT_COMPRESSION = 'zstd';
my $DEFAULT_RETENTION = '7d';

my %args = (
    "configdir" => "/etc/backoid",
	"run-dir" => "/var/run/backoid"
);
GetOptions(\%args, "configdir=s", "run-dir=s", "debug", "verbose") or pod2usage;

my $conf_file = "$args{'configdir'}/backoid.conf";
my $default_conf_file = "$args{'configdir'}/backoid.defaults.conf";

my $debug = $args{'debug'};
my $verbose = $args{'verbose'};

my $run_dir = $args{'run-dir'};

make_path($run_dir);

my $object_ttl = 2592000; # in seconds, 30 days

if (scalar(@ARGV) != 0) {
	pod2usage(2);
	exit 127;
}

my $sourceisroot = 0 + ($< == 0);
my %config = parse_config($conf_file, $default_conf_file);

upload_snapshots();
purge_snapshots();

sub upload_snapshots {
	if (checklock('backoid')) {
		writelock('backoid');

		my %remotes = get_remotes();
		my %uploaded_snapshots = get_uploaded_snapshots();
		foreach my $dataset (keys %{$config{'datasets'}}) {
			upload_dataset(\%{$config{'datasets'}{$dataset}}, \%uploaded_snapshots, \%remotes);
		}

		removelock('backoid');
	} else {
		if ($verbose) { print "INFO: deferring snapshot uploading - valid uploading lock held by other backoid process.\n"; }
	}
}

sub purge_snapshots {
	if (checklock('backoid')) {
		writelock('backoid');

		my %uploaded_snapshots = get_uploaded_snapshots();
		foreach my $dataset (keys %{$config{'datasets'}}) {
			my ($retention_number, $retention_suffix) = ($config{'datasets'}{$dataset}{'retention'} =~ /^(\d+)([hdwmy]?)$/);
			if (! length($retention_number) && ! length($retention_suffix)) {
				warn "CRITICAL ERROR: auto purge for dataset '$dataset' is cancelled, backoid doesn't know how to deal with retention '$config{'datasets'}{$dataset}{'retention'}'";
				next;
			}

			my @purging_objects = ();
			foreach my $snapshot (keys %uploaded_snapshots) {
				if (! ($snapshot =~ /^$dataset@/)) {
					# not the snapshot of current dataset
					next;
				}

				my %snapshot_obj = %{$uploaded_snapshots{$snapshot}};

				# auto purge by keeping certain amount of most recent snapshot tarballs,
				# no need to check if the snapshot tarball is expired.
				if ($retention_suffix eq '') {
					my $datetime = "$snapshot_obj{'year'}-$snapshot_obj{'month'}-$snapshot_obj{'day'} $snapshot_obj{'hour'}:$snapshot_obj{'minute'}:$snapshot_obj{'second'}";
					my $i = 0;
					foreach my $purging_object (@purging_objects) {
						if ($purging_object->{'datetime'} gt $datetime) {
							last;
						}

						$i = $i + 1;
					}
					splice(@purging_objects, $i, 0, {
						'datetime' => $datetime,
						'object' => $snapshot_obj{'object'}
					});
					next;
				}

				my $seconds = retention_to_seconds($retention_number, $retention_suffix);

				if (time() - timelocal($snapshot_obj{'second'},$snapshot_obj{'minute'},$snapshot_obj{'hour'},$snapshot_obj{'day'},$snapshot_obj{'month'}-1,$snapshot_obj{'year'}) < $seconds) {
					next;
				}

				my $object_path = catfile($config{'datasets'}{$dataset}{'target'}, $snapshot_obj{'object'});
				if ($debug) { print "DEBUG: found expired object $object_path\n"; }
				system("$rclone deletefile $object_path") == 0
					or warn "CRITICAL ERROR: purge object $object_path failed, exit code: $?";
			}

			if (scalar(@purging_objects) > 0 && scalar(@purging_objects) > $retention_number) {
				for my $index (0 .. (scalar(@purging_objects) - $retention_number - 1)) {
					my $object_path = catfile($config{'datasets'}{$dataset}{'target'}, $purging_objects[$index]->{'object'});
					if ($debug) { print "DEBUG: purging object $object_path\n"; }
					system("$rclone deletefile $object_path") == 0
						or warn "CRITICAL ERROR: purge object $object_path failed, exit code: $?";
				}
			}
		}

		removelock('backoid');
	} else {
		if ($verbose) { print "INFO: deferring snapshot purging - valid purging lock held by other backoid process.\n"; }
	}
}

sub parse_config {
	my ($conf_file, $default_conf_file) = @_;
	my %config = (
		'datasets' => {}
	);

	unless (-e $default_conf_file ) { die "FATAL: cannot load $default_conf_file - please restore a clean copy, this is not a user-editable file!"; }
	unless (-e $conf_file ) { die "FATAL: cannot load $conf_file - please create a valid local config file before running backoid!"; }

	tie my %defaults, 'Config::IniFiles', ( -file => $default_conf_file ) or die "FATAL: cannot load $default_conf_file - please restore a clean copy, this is not a user-editable file!";
	tie my %ini, 'Config::IniFiles', ( -file => $conf_file ) or die "FATAL: cannot load $conf_file - please create a valid local config file before running backoid!";

	my @istrue=(1,"true","True","TRUE","yes","Yes","YES","on","On","ON");

	foreach my $section (keys %ini) {
		# first up - die with honor if unknown parameters are set in any modules or templates by the user.
		foreach my $key (keys %{$ini{$section}}) {
			if (! defined ($defaults{'template_default'}{$key})) {
				die "FATAL ERROR: I don't understand the setting $key you've set in \[$section\] in $conf_file.\n";
			}

			# in case of duplicate lines we will end up with an array of all values
			my $value = $ini{$section}{$key};
			if (ref($value) eq 'ARRAY') {
				warn "duplicate key '$key' in section '$section', using the value from the first occurence and ignoring the others.\n";
				$ini{$section}{$key} = $value->[0];
			}
		}

		if ($section =~ /^template_/) { next; } # don't process templates directly

		# set default values from %defaults, which can then be overridden by template
        # and/or local settings within the module.
		foreach my $key (keys %{$defaults{'template_default'}}) {
			$config{'datasets'}{$section}{$key} = $defaults{'template_default'}{$key};
		}

		# override with values from user-defined default template, if any
		foreach my $key (keys %{$ini{'template_default'}}) {
			$config{'datasets'}{$section}{$key} = $ini{'template_default'}{$key};
		}

		# override with values from user-defined templates applied to this module,
		# in the order they were specified (ie use_template = default,production,mytemplate)
		if (defined $ini{$section}{'use_template'}) {
			my @templates = split (' *, *',$ini{$section}{'use_template'});
			foreach my $rawtemplate (@templates) {
				# strip trailing whitespace
				$rawtemplate =~ s/\s+$//g;

				my $template = 'template_'.$rawtemplate;
				foreach my $key (keys %{$ini{$template}}) {
					$config{'datasets'}{$section}{$key} = $ini{$template}{$key};
				}
			}
		}

		# override with any locally set values in the module itself
		foreach my $key (keys %{$ini{$section}}) {
			$config{'datasets'}{$section}{$key} = $ini{$section}{$key};
		}

		# target not defined, which means no upload
		if (!defined ($config{'datasets'}{$section}{'target'}) || !length ($config{'datasets'}{$section}{'target'})) {
			delete $config{'datasets'}{$section};
			next;
		}

		if ($config{'datasets'}{$section}{'retention'} eq '') {
			$config{'datasets'}{$section}{'retention'} = $DEFAULT_RETENTION;
		}

		my @zfs_datasets = get_zfs_datasets($section, 0);
		my $zfs_dataset = shift @zfs_datasets;
		$config{'datasets'}{$section}{'mountpoint'} = $zfs_dataset->{'mountpoint'};
	}

	foreach my $dataset (keys %{$config{'datasets'}}) {
		$config{'datasets'}{$dataset}{'snapshots'} = [];

		my @snapshots = get_snapshots($dataset, $config{'datasets'}{$dataset}{'retention'}, $config{'datasets'}{$dataset}{'pattern'});
		foreach my $snapshot (@snapshots) {
			my $trim_snapshot = $snapshot;
			$trim_snapshot =~ s/^$dataset@//;
			my $snapshot_path = '';

			if ($config{'datasets'}{$dataset}{'mountpoint'} ne "") {
				$snapshot_path = catfile($config{'datasets'}{$dataset}{'mountpoint'}, ".zfs", "snapshot", "$trim_snapshot");
			}

			if ($debug) { print "DEBUG: found snapshot $snapshot\n"; }
			my %snapshot_obj = (
				'name' => "$snapshot",
				'path' => "$snapshot_path"
			);
			push(@{$config{'datasets'}{$dataset}{'snapshots'}}, \%snapshot_obj);
		}
	}

	return %config;
}

sub get_zfs_datasets {
	my ($path, $recursive) = @_;
	my $recursive_arg = '';
	if ($recursive) {
		$recursive_arg = 'r';
	}

	my $cmd = "$zfs list -o name,mountpoint -t filesystem,volume -H$recursive_arg $path";
	if ($debug) { print "DEBUG: getting list of datasets of $path using '$cmd' ...\n"; }
	my @lines = `$cmd`;
	my $exit_code = $? >> 8;

	if ($exit_code != 0) {
		warn "CRITICAL ERROR: command '$cmd' failed with exit code: $exit_code";
		return ();
	}

	my @datasets = ();
	foreach my $line (@lines) {
		my ($name, $mountpoint) = $line =~ /^([^\t]+)\t([^\t]+)/;
		chomp $name;
		chomp $mountpoint;

		if (grep $mountpoint eq $_, ('-', 'legacy', 'none')) {
			$mountpoint = '';
		}

		my %dataset = (
			'name' => "$name",
			'mountpoint' => "$mountpoint"
		);
		push(@datasets, \%dataset);
	}

	return @datasets;
}

sub get_snapshots {
	my ($dataset, $retention, $pattern) = @_;
	my ($retention_number, $retention_suffix) = ($retention =~ /^(\d+)([hdwmy]?)$/);
	my @snapshots = ();

	my $cmd = "$zfs list -o name,creation -t snapshot -S creation -H -p $dataset 2>&1";
	 if ($debug) { print "DEBUG: getting list of snapshots of $dataset using '$cmd' ...\n"; }
	my @lines = `$cmd`;
	my $exit_code = $? >> 8;

	if ($exit_code != 0) {
		warn "CRITICAL ERROR: command '$cmd' failed with exit code: $exit_code";
		return ();
	}

	my $snapshot_count = 0;
	my $out_of_retention = 0;
	foreach my $line (@lines) {
		chomp $line;

		my ($snapshot, $timestamp) = ($line =~ /^\s*(\S+)\s+(\d+)\s*$/);
		if (! length($snapshot)) {
			warn "CRITICAL ERROR: get unexpected line from zfs list output -> '$line'";
			next;
		}

		my $trim_snapshot = $snapshot;
		$trim_snapshot =~ s/^$dataset@//;
		if (! ($trim_snapshot =~ /$pattern/)) {
			next;
		}

		if ($retention_suffix ne '') {
			my $seconds = retention_to_seconds($retention_number, $retention_suffix);
			if (time() - $timestamp >= $seconds) {
				$out_of_retention = 1;
			}
		} elsif ($snapshot_count >= $retention_number) {
			$out_of_retention = 1;
		}

		if ($out_of_retention) {
			last;
		}

		push(@snapshots, $snapshot);
		$snapshot_count += 1;
	}

	return @snapshots;
}

sub upload_dataset {
	my ($dataset, $uploaded_snapshots, $remotes) = @_;
	my $compressor = get_compressor($dataset->{'compression'}, $dataset->{'compression_level'});

	foreach my $snapshot (@{$dataset->{'snapshots'}}) {
		if (defined $uploaded_snapshots->{$snapshot->{'name'}}) {
			# same snapshot is uploaded, ignore
			if ($verbose) { print "INFO: deferring snapshot uploading - snapshot $snapshot->{'name'} already uploaded.\n"; }
			next;
		}

		my $file_arg = '';
		my $cmd = '';
		my $snapshot_name;
		my $snapshot_size;
		my $snapshot_cmd;
		my $cleanup_cmd;
		if (-e "$snapshot->{'path'}") {
			my $snapshot_dir = dirname("$snapshot->{'path'}");
			$snapshot_name = basename("$snapshot->{'path'}");
			$snapshot_size = (`du -s --apparent-size $snapshot->{'path'}` =~ /^\s*(\w+)\s+/) * 1024; # du returns size in KB

			$snapshot_cmd = "tar cf - -C $snapshot_dir $snapshot_name";
			# When taring up snapshots in the invisible .zfs path finishes,
			# zfs leaves the path mounted. Therefore we make a cleanup
			# command to unmount it.
			$cleanup_cmd = "umount $snapshot->{'path'}";
		} else {
			$snapshot_name = $snapshot->{'name'};
			$snapshot_name =~ s#@#/#;
			$snapshot_name = basename($snapshot_name);

			my $cmd = "$zfs send -nP $snapshot->{'name'}";
			if ($debug) { print "DEBUG: getting size of snapshot '$snapshot->{'name'}' using '$cmd' ...\n"; }
			my @lines = `$cmd`;
			my $exit_code = $? >> 8;

			if ($exit_code != 0) {
				warn "CRITICAL ERROR: command '$cmd' failed with exit code: $exit_code";
				return;
			}

			my @size_fields = split /\s+/, $lines[-1];
			$snapshot_size = $size_fields[-1];
			if (!defined $snapshot_size || $snapshot_size =~ /\D/) {
				warn "CRITICAL ERROR: got an unexpected return from '$zfs send -cp -nP $snapshot->{'name'}': $snapshot_size";
				return;
			}

			$snapshot_cmd = "$zfs send $dataset->{'zfs_send_options'} $snapshot->{'name'}";
		}

		my $remote_path = catfile($dataset->{'target'}, $snapshot_name);

		my $verbose_arg = '';
		my $pv_cmd = '';
		if ($verbose) {
			$verbose_arg = '-v';
			$pv_cmd = "| $pv --name $snapshot->{'name'} -s $snapshot_size";
		}

		my $compressor_cmd = defined $compressor ? '| ' . gen_compressor_cmd($compressor, $snapshot_size, $dataset->{'compressor_options'}) : '';
		my $compression_ext = defined $compressor ? ".tar.$compressor->{'extension'}" : '';

		my $check_cmd = "$rclone ls $dataset->{'target'} --include $snapshot_name.tar.*";
		if ($debug) { print "DEBUG: checking if snapshot $snapshot->{'name'} exists on remote with command '$check_cmd' ...\n"; }
		my $check_output = `$check_cmd`;
		if ($check_output ne '') {
			if ($verbose) { print "INFO: deferring snapshot uploading - snapshot $snapshot->{'name'} already exists on remote.\n"; }
			next;
		}

		my $rclone_options = $dataset->{'rclone_options'};
		if (!defined $rclone_options) {
			$rclone_options = '';
		}
		my $remote_name = (split ':', $dataset->{'target'})[0] . ':';
		if (exists $remotes->{$remote_name} && $remotes->{$remote_name}->{'type'} eq 's3' && $rclone_options !~ /--s3-chunk-size/) {
			my $s3_chunk_size = ceil($snapshot_size / (8 * 1024 * 1024 * 1024)) + (defined $compressor ? 0 : 1) * 2;
			if ($s3_chunk_size < 5) {
				$s3_chunk_size = 5; # min s3 chunk size for multipart uploads
			}
			if ($s3_chunk_size > (5 * 1024)) {
				warn "WARN: using maximum --s3-chunk-size (5GB) to upload $snapshot->{'name'} due to its large size";
				$s3_chunk_size = 5 * 1024;
			}
			$rclone_options .= " --s3-chunk-size ${s3_chunk_size}M";
		}

		$cmd = "$snapshot_cmd | mbuffer -q -Q -s 128K -m 16M $pv_cmd $compressor_cmd | $rclone $verbose_arg $rclone_options rcat ${remote_path}${compression_ext} && $rclone touch --no-create ${remote_path}${compression_ext}";
		if ($debug) { print "DEBUG: taring up and uploading snapshot $snapshot->{'name'} with command '$cmd' ...\n"; }
		my $exit_code = system("$cmd");
		if (defined $cleanup_cmd) {
			system("$cleanup_cmd");
		}
		if ($exit_code != 0) {
			warn "CRITICAL ERROR: taring up and uploading snapshot $snapshot->{'name'} failed";
			return;
		};
	}
}

sub get_compressor {
	my ($compression, $level) = @_;
	if (length $level) {
		$level =~ s/^\s+|\s+$//g;
	} else {
		$level = '';
	}

	my %COMPRESS_ARGS = (
		'bzip2' => {
			bin 		=> 'bzip2',
			level		=> '',
			extension	=> 'bz2',
		},
		'gzip' => {
			bin			=> 'gzip',
			level		=> '',
			extension	=> 'gz',
		},
		'lz4' => {
			bin			=> 'lz4',
			level		=> '',
			extension	=> 'lz4',
		},
		'pbzip2' => {
			bin			=> 'pbzip2',
			level		=> '',
			extension	=> 'bz2',
		},
		'pigz' => {
			bin			=> 'pigz',
			level		=> '',
			extension	=> 'gz',
		},
		'zstd' => {
			bin			=> 'zstd',
			level		=> '',
			extension	=> 'zst',
		},
		'pzstd' => {
			bin			=> 'pzstd',
			level		=> '',
			extension	=> 'zst',
		},
		'pxz' => {
			bin			=> 'pxz',
			level		=> '',
			extension	=> 'xz',
		},
		'xz' => {
			bin			=> 'xz',
			level		=> '',
			extension	=> 'xz',
		},
		'none' => undef
	);

	if ($compression eq '') {
		$compression = $DEFAULT_COMPRESSION;
	} elsif (!(grep $compression eq $_, ((keys %COMPRESS_ARGS), 'default'))) {
		die "Unrecognised compression type '$compression'";
	}

	my $compressor = $COMPRESS_ARGS{$compression};
	if (defined $compressor && $level ne '') {
		$compressor->{'level'} = "-$level";
	}

	return $compressor;
}

sub gen_compressor_cmd {
	my ($compressor, $snapshot_size, $compressor_options) = @_;
	my $cmd = "$compressor->{'bin'} $compressor->{'level'} $compressor_options";

	if ($compressor->{'extension'} eq 'zst') {
		my $size_hint = $snapshot_size < 2**32 ? $snapshot_size : (int($snapshot_size / (2**30) + 0.5) . 'G');
		$cmd = "$cmd --size-hint=$size_hint";
	}

	return "$cmd -";
}

sub checklock {
	# take argument $lockname.
	#
	# read $run_dir/$lockname.lock for a pid on first line and a mutex on second line.
	#
	# check process list to see if the pid from $run_dir/$lockname.lock is still active with
	# the original mutex found in $run_dir/$lockname.lock.
	#
	# return:
	#    0 if lock is present and valid for another process
	#    1 if no lock is present
	#    2 if lock is present, but we own the lock
	#
	# shorthand - any true return indicates we are clear to lock; a false return indicates
	#             that somebody else already has the lock and therefore we cannot.
	#

	my $lockname = shift;
	my $lockfile = "$run_dir/$lockname.lock";

	if (! -e $lockfile) {
		# no lockfile
		return 1;
	}
	# make sure lockfile contains something
	if ( -z $lockfile) {
	        # zero size lockfile, something is wrong
	        warn "WARN: deleting invalid/empty $lockfile\n";
	        unlink $lockfile;
	        return 1
	}

	# lockfile exists. read pid and mutex from it. see if it's our pid.  if not, see if
	# there's still a process running with that pid and with the same mutex.

	open FH, "< $lockfile" or die "ERROR: unable to open $lockfile";
	my @lock = <FH>;
	close FH;
	# if we didn't get exactly 2 items from the lock file there is a problem
	if (scalar(@lock) != 2) {
	    warn "WARN: deleting invalid $lockfile\n";
	    unlink $lockfile;
	    return 1
	}

	my $lockmutex = pop(@lock);
	my $lockpid = pop(@lock);

	chomp $lockmutex;
	chomp $lockpid;

	if ($lockpid == $$) {
		# we own the lockfile. no need to check any further.
		return 2;
	}
	open PL, "$ps -p $lockpid -o args= |";
	my @processlist = <PL>;
	close PL;

	my $checkmutex = pop(@processlist);
	chomp $checkmutex;

	if ($checkmutex eq $lockmutex) {
		# lock exists, is valid, is not owned by us - return false
		return 0;
	} else {
		# lock is present but not valid - remove and return true
		unlink $lockfile;
		return 1;
	}
}

sub removelock {
	# take argument $lockname.
	#
	# make sure $run_dir/$lockname.lock actually belongs to me (contains my pid and mutex)
	# and remove it if it does, die if it doesn't.

	my $lockname = shift;
	my $lockfile = "$run_dir/$lockname.lock";

	if (checklock($lockname) == 2) {
		unlink $lockfile;
		return;
	} elsif (checklock($lockname) == 1) {
		die "ERROR: No valid lockfile found - Did a rogue process or user update or delete it?\n";
	} else {
		die "ERROR: A valid lockfile exists but does not belong to me! I refuse to remove it.\n";
	}
}

sub writelock {
	# take argument $lockname.
	#
	# write a lockfile to $run_dir/$lockname.lock with first line
	# being my pid and second line being my mutex.

	my $lockname = shift;
	my $lockfile = "$run_dir/$lockname.lock";

	# die honorably rather than overwriting a valid, existing lock
	if (! checklock($lockname)) {
		die "ERROR: Valid lock already exists - I refuse to overwrite it. Committing seppuku now.\n";
	}

	my $pid = $$;

	open PL, "$ps -p $$ -o args= |";
	my @processlist = <PL>;
	close PL;

	my $mutex = pop(@processlist);
	chomp $mutex;

	open FH, "> $lockfile";
	print FH "$pid\n";
	print FH "$mutex\n";
	close FH;
}

sub get_uploaded_snapshots {
	my %snapshots = ();

	foreach my $dataset (keys %{$config{'datasets'}}) {
		my @lines = `$rclone lsl --max-depth 1 $config{'datasets'}{$dataset}{'target'}`;
		foreach my $line (@lines) {
			my ($size, $year, $month, $day, $hour, $minute, $second, $object) = $line =~ /^\s*(\d+)\s+(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\.\d+\s+([^\s]+)/;
			if (! length($object)) {
				warn "CRITICAL ERROR: get unexpected line from rclone lsl output -> '$line'";
				next;
			}

			my $trim_object = $object;
			$trim_object =~ s/\.tar\.\w+$//;
			if (! ($trim_object =~ /$config{'datasets'}{$dataset}{'pattern'}/)) {
				next;
			}

			my $name = "$dataset\@$trim_object";
			$snapshots{$name} = {
				'year' => $year,
				'month'=> $month,
				'day' => $day,
				'hour' => $hour,
				'minute' => $minute,
				'second' => $second,
				'object' => $object
			}
		}
	}

	return %snapshots;
}

sub get_remotes {
	my %remotes = ();

	my @lines = `$rclone listremotes --long`;
	foreach my $line (@lines) {
		my ($name, $type) = $line =~ /^\s*(\S+)\s+(\S+)/;
		$remotes{$name} = {
			'type' => $type
		}
	}

	return %remotes;
}

sub retention_to_seconds {
	my ($number, $suffix) = @_;

	my $seconds = 60 * 60;
	if ($suffix eq 'h') {
		$seconds = $seconds * $number;
	} elsif ($suffix eq 'd') {
		$seconds = $seconds * 24 * $number;
	} elsif ($suffix eq 'w') {
		$seconds = $seconds * 24 * 7 * $number;
	} elsif ($suffix eq 'm') {
		$seconds = $seconds * 24 * 30 * $number;
	} else { # year
		$seconds = $seconds * 24 * 365 * $number;
	}

	return $seconds;
}

__END__

=head1 NAME

backoid - a sanoid/syncoid-like utility for object storage backup targets

=head1 SYNOPSIS

backoid [options]

Options:

  --configdir=DIR       Specify a directory to find config file backoid.conf
  --run-dir=DIR         Specify a directory for temporary files such as lock files

  --verbose             Prints out additional information during a backoid run
  --debug               Prints out a lot of additional information during a backoid run

  --help                Prints this helptext
  --version             Prints the version number
