package Brackup::Target::Filesystem;
use strict;
use warnings;
use base 'Brackup::Target';
use File::Basename;
use File::Find ();
use File::Path;
use File::stat ();
sub new {
my ($class, $confsec) = @_;
my $self = $class->SUPER::new($confsec);
$self->{path} = $confsec->path_value("path");
$self->{nocolons} = $confsec->value("no_filename_colons");
$self->{nocolons} = ($^O eq 'MSWin32') unless defined $self->{nocolons}; # LAME: Make it work on Windows
# see if we're operating in a pre-1.06 environment
if (opendir(my $dh, $self->{path})) {
$self->{_no_four_hex_dirs_in_root} = 1;
while (my $file = readdir($dh)) {
if ($file =~ /^[0-9a-f]{4}$/) {
$self->{_no_four_hex_dirs_in_root} = 0;
}
}
}
if ($ENV{BRACKUP_REARRANGE_FS_TARGET}) {
$self->_upgrade_layout;
}
return $self;
}
sub new_from_backup_header {
my ($class, $header) = @_;
my $self = bless {}, $class;
$self->{path} = $header->{"BackupPath"} or
die "No BackupPath specified in the backup metafile.\n";
unless (-d $self->{path}) {
die "Restore path $self->{path} doesn't exist.\n";
}
return $self;
}
sub backup_header {
my $self = shift;
return {
"BackupPath" => $self->{path},
};
}
# 1.05 and before stored files on disk as: xxxx/xxxx/xxxxxxxxxx.brackup
# (that is, two levels of directories, each 4 hex digits long, or 65536
# files per directory, which is 2x what ext3 can store, leading to errors.
# in 1.06 and above, xx/xx/xxxxxx is used. that is, two levels of 2 hex
# digits. this function
sub _upgrade_layout {
my $self = shift;
my $clean_limit = shift; # optional; if set, max top-level dirs to clean
my $root = $self->{path};
opendir(my $dh, $root) or die "Error opening $root: $!";
# read the current state of things in the root directory
# (which is presumably maxed out on files, at 32k or whatnot)
my %exist_twodir; # two_dir -> 1 (which two-letter directories exist)
my %exist_fourdir; # four_dir -> 1 (which four-letter directories exist)
my %four_of_two; # two_dir -> [ four_dir, four_dir, ... ]
while (my $dir = readdir($dh)) {
next unless -d "$root/$dir";
if ($dir =~ /^[0-9a-f]{2}$/) {
$exist_twodir{$dir} = 1;
next;
}
if ($dir =~ /^([0-9a-f]{2})([0-9a-f]{2})$/) {
$exist_fourdir{"$1$2"} = 1;
push @{ $four_of_two{$1} ||= [] }, "$1$2";
}
}
# for each 4-digit directory, sorted by number of four-digit directories
# that exist for their leading 2-digit prefix (to most quickly free up
# a link in root, in 2 iterations),
# see if the "01/" directory exists (the leading two bytes).
# if not,
# move it to some random other 'xxxx' directory,
# as, say, "abcd/tmp-was-root-0123".
# now, for either the "0123" directory or "tmp-was-root-0123"
# directory, file all chunks, and move them to the
# right locations "01/23/*.chunk", making "01/23" if needed.
# (shouldn't be any out-of-link problems down one level)
my @four_dirs = map {
sort @{ $four_of_two{$_} }
}
sort {
scalar(@{ $four_of_two{$b} }) <=> scalar(@{ $four_of_two{$a} })
} keys %four_of_two;
my $n_done;
while (my $four_dir = shift @four_dirs) {
my $leading_two = substr($four_dir, 0, 2);
my $migrate_source;
if ($exist_twodir{$leading_two}) {
# top-level destination already exists. no need for more
# links in the top-level
$migrate_source = $four_dir;
} elsif (@four_dirs) {
# we need to move four_dir away, into another four_dir,
# to make room to create a new two_dir in the root
my $holder_four_dir = $four_dirs[0];
$migrate_source = "$holder_four_dir/tmp-was-root-$four_dir";
my $temp_dir = "$root/$migrate_source";
rename "$root/$four_dir", $temp_dir
or die "Rename of $root/$four_dir -> $temp_dir failed: $!";
} else {
# no four_dirs left? then I bet we aren't out of links
# anymore. just migrate.
$migrate_source = $four_dir;
}
$self->_upgrade_chunks_in_directory($four_dir, $migrate_source);
if (-e "$root/$four_dir") {
die "Upgrade of $root/$four_dir/* didn't seem to have worked.";
}
$n_done++;
last if $clean_limit && $n_done >= $clean_limit;
}
}
sub _upgrade_chunks_in_directory {
my $self = shift;
my $four_dig = shift; # first four hex digits of all files being moved
my $rel_dir = shift; # directory (relative to root) to move files from, and then remove
die "not relative" unless $rel_dir =~ m!^[^/]!;
my $root = $self->{path};
my ($hex12, $hex34) = $four_dig =~ /^([0-9a-f]{2})([0-9a-f]{2})$/
or die "four_dig not four hex digits";
my $dest_dir0 = "$root/$hex12";
my $dest_dir = "$root/$hex12/$hex34";
for ($dest_dir0, $dest_dir) {
next if -d $_;
mkdir $_ or die "Failed to mkdir $_: $!";
}
my @dirs;
File::Find::find({wanted => sub {
my $name = $File::Find::name;
if (-f $name) {
my $basefile = $_; # stupid File::Find conventions
rename $name, "$dest_dir/$basefile" or die
"Failed to move $name to $dest_dir: $!";
} elsif (-d $name) {
return if $_ eq "." || $_ eq "..";
push @dirs, $name;
}
}}, "$root/$rel_dir");
my $final_dir = "$root/$four_dig";
for my $dir (reverse(@dirs), $final_dir) {
if (!rmdir($dir) && -d $dir) {
warn "Directory not empty? $dir. Skipping cleanup.\n";
return;
}
}
warn "Rearranged & removed $four_dig\n";
}
# version <= 1.05: 0123/4567/89ab/cdef/0123456789abcdef...xxx.chunk
# this is totally stupid. 65k files in root (twice ext3's historical/common
# maximum), and the leaves were always containing but one file.
sub _old_diskpath {
my ($self, $dig) = @_;
my @parts;
my $fulldig = $dig;
$dig =~ s/^\w+://; # remove the "hashtype:" from beginning
$fulldig =~ s/:/./g if $self->{nocolons}; # Convert colons to dots if we've been asked to
while (length $dig && @parts < 4) {
$dig =~ s/^([0-9a-f]{4})// or die "Can't get 4 hex digits of $fulldig";
push @parts, $1;
}
return $self->{path} . "/" . join("/", @parts) . "/$fulldig.chunk";
}
# version >= 1.06: 01/23/0123456789abcdef...xxx.chunk
# 256 * 256 directories, then files. would need 2 billion
# files before leaves have 32k+ files, but at that point
# users are probably using better filesystems if they
# have 2+ inodes.
sub _new_diskpath {
my ($self, $dig) = @_;
my @parts;
my $fulldig = $dig;
$dig =~ s/^\w+://; # remove the "hashtype:" from beginning
$fulldig =~ s/:/./g if $self->{nocolons}; # Convert colons to dots if we've been asked to
while (length $dig && @parts < 2) {
$dig =~ s/^([0-9a-f]{2})// or die "Can't get 2 hex digits of $fulldig";
push @parts, $1;
}
return $self->{path} . "/" . join("/", @parts) . "/$fulldig.chunk";
}
sub chunkpath {
my ($self, $dig) = @_;
# if the old (version <= 1.05) chunk still exists,
# just use that, unless we know (from initial scan)
# that such paths can't exist, thus avoiding a
# bunch of stats()
unless ($self->{_no_four_hex_dirs_in_root}) {
my $old = $self->_old_diskpath($dig);
return $old if -e $old;
}
# else, use the new (version >= 1.06) location, which
# is much more sensible
return $self->_new_diskpath($dig);
}
sub has_chunk_of_handle {
my ($self, $handle) = @_;
my $dig = $handle->digest; # "sha1:sdfsdf" format scalar
my $path = $self->chunkpath($dig);
return -e $path;
}
sub load_chunk {
my ($self, $dig) = @_;
my $path = $self->chunkpath($dig);
open (my $fh, $path) or die "Error opening $path to load chunk: $!";
my $chunk = do { local $/; <$fh>; };
return \$chunk;
}
sub store_chunk {
my ($self, $chunk) = @_;
my $dig = $chunk->backup_digest;
my $blen = $chunk->backup_length;
my $path = $self->chunkpath($dig);
my $dir = $path;
$dir =~ s!/[^/]+$!!;
unless (-d $dir) {
unless (eval { File::Path::mkpath($dir) }) {
if ($!{EMLINK}) {
warn "Too many directories in one directory; doing partial cleanup before proceeding...\n";
# NOTE: 2 directories is key to freeing up one link. imagine upgrading one:
# it'd remove "0000" but possibly (likely) create "00". so we do two,
# because, following the example, "0001" would also go into "00", so we'd have one
# link left in the root. _upgrade_layout orders the directories to clean in
# an order such that 2 will succeed or fail, but no higher will succeed when
# 2 won't.
$self->_upgrade_layout(2);
unless (eval { File::Path::mkpath($dir) }) {
die "Still can't create directory $dir: $!\n";
}
} else {
die "Failed to mkdir: $dir: $!\n";
}
}
}
open (my $fh, ">$path") or die "Failed to open $path for writing: $!\n";
binmode($fh);
my $chunkref = $chunk->chunkref;
print $fh $$chunkref;
close($fh) or die "Failed to close $path\n";
my $actual_size = -s $path;
my $expected_size = length $$chunkref;
unless (defined($actual_size)) {
die "Chunk output file $path does not exist. Do you need to set no_filename_colons=1?";
}
unless ($actual_size == $expected_size) {
die "Chunk $path was written to disk wrong: size is $actual_size, expecting $expected_size\n";
}
return 1;
}
sub delete_chunk {
my ($self, $dig) = @_;
my $path = $self->chunkpath($dig);
unlink $path;
}
# returns a list of names of all chunks
sub chunks {
my $self = shift;
my @chunks = ();
my $found_chunk = sub {
m/\.chunk$/ or return;
my $chunk_name = basename($_);
$chunk_name =~ s/\.chunk$//;
push @chunks, $chunk_name;
};
File::Find::find({ wanted => $found_chunk, no_chdir => 1}, $self->{path});
return @chunks;
}
sub _metafile_dir {
return $_[0]->{path}."/backups/";
}
sub store_backup_meta {
my ($self, $name, $file) = @_;
my $dir = $self->_metafile_dir;
unless (-d $dir) {
mkdir $dir or die "Failed to mkdir $dir: $!\n";
}
open (my $fh, ">$dir/$name.brackup") or die;
print $fh $file;
close $fh or die;
return 1;
}
sub backups {
my ($self) = @_;
my $dir = $self->_metafile_dir;
return () unless -d $dir;
opendir(my $dh, $dir) or
die "Failed to open $dir: $!\n";
my @ret = ();
while (my $fn = readdir($dh)) {
next unless $fn =~ s/\.brackup$//;
my $stat = File::stat::stat("$dir/$fn.brackup");
push @ret, Brackup::TargetBackupStatInfo->new($self, $fn,
time => $stat->mtime,
size => $stat->size);
}
closedir($dh);
return @ret;
}
# downloads the given backup name to the current directory (with
# *.brackup extension) or to the specified location
sub get_backup {
my ($self, $name, $output_file) = @_;
my $dir = $self->_metafile_dir;
my $file = "$dir/$name.brackup";
die "File doesn't exist: $file" unless -e $file;
open(my $in, $file) or die "Failed to open $file: $!\n";
$output_file ||= "$name.brackup";
open(my $out, ">$output_file") or die "Failed to open $output_file: $!\n";
my $buf;
my $rv;
while ($rv = sysread($in, $buf, 128*1024)) {
my $outv = syswrite($out, $buf);
die "copy error" unless $outv == $rv;
}
die "copy error" unless defined $rv;
return 1;
}
sub delete_backup {
my $self = shift;
my $name = shift;
my $file = sprintf '%s/%s.brackup', $self->_metafile_dir, $name;
die "File doesn't exist: $file" unless -e $file;
unlink $file;
return 1;
}
1;
=head1 NAME
Brackup::Target::Filesystem - backup to a locally mounted filesystem
=head1 DESCRIPTION
Back up to an NFS or Samba server, another disk array (external storage), etc.
=head1 EXAMPLE
In your ~/.brackup.conf file:
[TARGET:nfs_in_garage]
type = Filesystem
path = /mnt/nfs-garage/brackup/
=head1 CONFIG OPTIONS
=over
=item B<type>
Must be "B<Filesystem>".
=item B<path>
Path to backup to.
=back
=head1 SEE ALSO
L<Brackup::Target>
syntax highlighted by Code2HTML, v. 0.9.1