#!/usr/bin/perl -ws
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to:
#
#  Free Software Foundation, Inc.
#  59 Temple Place - Suite 330
#  Boston, MA 02111-1307, USA.

# Rudimentary switch parsing. Must be in main package.
our $cleanup;

package BBM;
use strict;
use POSIX qw(setuid setgid);
use DBI;
use File::Temp qw(tempdir);
use File::Path qw(rmtree);

# path to Apache HTTPd-style config
my $config = '/etc/bacula/backup-mysql.conf';
my $c = new BBM::Config($config);

# now change to user mysql after we've read config
unless ($<) {
	my $uid = getpwnam('mysql');
	my $gid = getgrnam('mysql');
	die "Can't find user/group mysql\n" unless $uid or $gid;

	# CWD could not be accessible for mysql user
	chdir("/");

	$) = "$gid $gid";
	$( = $gid;
	$> = $< = $uid;
}

# setup tmpdir
my $backup_dir = $c->get('options', 'outdir') or die "'outdir' not defined in config\n";
my $tmpdir = $c->get('options', 'tmpdir') or die "'tmpdir' not defined in config\n";

if (!-d $backup_dir && !mkdir($backup_dir) && !-d $backup_dir) {
	die "backup dir '$backup_dir' not present and can't be created\n";
}
if (!-d $tmpdir && !mkdir($tmpdir) && !-d $tmpdir) {
	die "tmpdir '$tmpdir' not present and can't be created\n";
}

# process each cluster
for my $cluster ($c->get('clusters', 'cluster')) {
	print ">>> cluster: $cluster\n";
	if ($cleanup) {
		cleanup_cluster($cluster);
	} else {
		backup_cluster($cluster);
	}
	print "<<< end cluster: $cluster\n";
}

#
# Usage: mysqlhotcopy $CLUSTER $DATABASE $USERNAME $PASSWORD $SOCKET
#
sub mysqlhotcopy {
	my ($cluster, $db, $user, $password, $socket) = @_;

	# strip $database to contain only db name, as the rest of the code assumes $database is just database name
	# i.e: include_database teensForum5./~(phorum_forums|phorum_users)/
	my ($database) = $db =~ /^([^\.]+)/;

	my $dstdir = tempdir("bbm.XXXXXX", DIR => $tmpdir);

	# remove output dir before backup,
	# otherwise the disk space requirement would double
	my $dirname = "$backup_dir/$cluster/$database";
	if (-d $dirname) {
		print ">>>> rmtree $dirname\n";
		rmtree($dirname);
	}

	# make backup with mysqlhotcopy
	my @shell = ('mysqlhotcopy');
	push(@shell, '-u', $user) if $user;
	push(@shell, '-p', $password) if $password;
	push(@shell, '-S', $socket) if $socket;
	push(@shell, $db, $dstdir);
	print ">>>> mysqlhotcopy $database\n";
	system(@shell) == 0 or die "mysqlhotcopy failed: $?\n";

	# put it to "production dir"
	my $cluster_dir = "$backup_dir/$cluster";
	if (!-d $cluster_dir && !mkdir($cluster_dir) && !-d $cluster_dir) {
		rmtree($dstdir);
		die "cluster dir '$cluster_dir' not present and can't be created\n";
	}

	my $srcdir = "$dstdir/$database";
	unless (rename($srcdir, $dirname)) {
		my $err = $!;
		rmtree($dstdir);
		die "Rename '$srcdir'->'$dirname' failed: $err\n";
	}

	rmdir($dstdir) or warn $!;

	print "<<<< mysqlhotcopy $database\n";
}

sub cleanup_cluster {
	my ($cluster) = @_;
	my $cluster_dir = "$backup_dir/$cluster";
	print ">>>> cleanup $cluster_dir\n";
	rmtree($cluster_dir);
	print "<<<< cleanup $cluster_dir\n";
}

sub backup_cluster {
	my ($cluster) = @_;

	# get db connection info
	my $user = $c->get($cluster, 'user') || $c->get('client', 'user');
	my $password = $c->get($cluster, 'password') || $c->get('client', 'password');
	my $socket = $c->get($cluster, 'socket') || $c->get('client', 'socket');

	# get databases to backup
	my @include = $c->get($cluster, 'include_database');
	my @exclude = $c->get($cluster, 'exclude_database');

	# start with include list
	my %dbs = map { $_ => 1 } @include;

	if (@exclude or !@include) {
		my $dbh = new BBM::DB($user, $password, $socket);
		my $sth = $dbh->prepare("show databases");
		$sth->execute();
		while (my($dbname) = $sth->fetchrow_array) {
			next if lc($dbname) eq 'information_schema';
			next if lc($dbname) eq 'performance_schema';
			$dbs{$dbname} = 1;
		}
		undef $dbh;
	}

	# remove excluded databases
	delete @dbs{@exclude};

	# now do the backup
	foreach my $db (keys %dbs) {
		mysqlhotcopy($cluster, $db, $user, $password, $socket);
	}
}

package BBM::DB;
use strict;

# DB class for simple Database connection
sub new {
	my $self = shift;
	my ($user, $password, $socket) = @_;
	my $dsn = '';
	$dsn .= "mysql_socket=$socket" if $socket;
	my $dbh = DBI->connect("DBI:mysql:$dsn", $user, $password, { PrintError => 0, RaiseError => 1 });
	return $dbh;
}

package BBM::Config;
use strict;
use Config::General;

sub new {
	my $self = shift;
	my $class = ref($self) || $self;
	my $file = shift;

	my $config = new Config::General(-ConfigFile => $file, -LowerCaseNames => 1);
	my $this = { $config->getall() };
	bless($this, $class);
}

sub get {
	my ($self, $section, $key) = @_;
	my $h = $self;

	# descend to [cluster] if $section not present in root tree
	unless (exists $h->{$section}) {
		$h = $h->{cluster};
	}

	# pay attention if callee wanted arrays
	return wantarray ? () : undef unless exists $h->{$section};
	return wantarray ? () : undef unless exists $h->{$section}->{$key};

	# deref if wanted array and is arrayref
	return @{$h->{$section}->{$key}} if wantarray && ref $h->{$section}->{$key} eq 'ARRAY';

	return $h->{$section}->{$key};
}


__END__

=head1 NAME

bacula-backup-mysql - A hook for Bacula to backup mysql databases using mysqlhotcopy.

=head1 SYNOPSIS

  Job {
    Name = "example.org-mysql"
    ...
    # This prepares the backup
    Client Run Before Job = "/usr/sbin/bacula-backup-mysql"
    # This deletes the copy of the catalog
    Client Run After Job = "/usr/sbin/bacula-backup-mysql -cleanup"
  }

=head1 DESCRIPTION

This is a script to be setup as C<Client Run Before Job> in Bacula.

=head1 CONFIGURATION

Config starts with C<E<lt>clustersE<gt>> block, which you can define several
database instances to be backed up.

Each C<cluster> should define connection dsn and tables to be backed up.

To define connection dsn, you can also define common options in
C<E<lt>clientE<gt>> section, those options would be shared for each cluster
which has not overriden the specific option.

In C<E<lt>clusterE<gt>> sections, you should list databases to be backed up.
You can use C<include_database> option to implicitly list which databases to be
backed up. If you omit C<include_database> option, all databases are backed up
which can be seen with C<show tables> query. To exclude databases from that
list you can use C<exclude_database> options.

Additionally C<include_database> supports table regexp, which is passed
directly to mysqlhotcopy(1). This allows you to include or exclude tables from
each database backup.

=head1 EXAMPLE CONFIGURATION

  # clusters to backup
  # there is nothing special about name "mysql", name just picked as convience
  <clusters>
    cluster mysql
    cluster eventum
    cluster forums
  </clusters>

  # client connection parameters, can be overriden per cluster
  <client>
    user mysql
    password secret
  </client>

  # global options, can be overriden per cluster
  <options>
    tmpdir /srv/bacula/tmp
    outdir /srv/bacula/mysql
  </options>

  # mysql cluster: backup all but mysql database
  <cluster mysql>
    socket /var/lib/mysql/mysql.sock
    exclude_database mysql
  </cluster>

  # forums cluster: exclude phorum_forums and phorum_users tables
  <cluster forums>
    user mysql
    socket /var/lib/mysql/mysql.sock

    # include_database can also support table regexp for mysqlhotcopy:
    include_database teensForum5./~(phorum_forums|phorum_users)/
  </cluster>

=head1 AUTHOR

Copyright (C) 2009-2012, Elan RuusamE<auml>e <glen@delfi.ee>

=head1 SEE ALSO

http://www.bacula.org/

=cut
