#!/usr/bin/env perl # # Update the DAViCal database by repeatedly applying patches to it # in the correct order. # use warnings; use strict; use DBI; use POSIX qw(floor); use Getopt::Long qw(:config permute); # allow mixed args. # Options variables my $debug = 0; my $dbname = "davical"; my $dbport = 5432; my $dbuser = "davical_dba"; my $dbpass = ""; my $dbhost = ""; my $appuser = "davical_app"; my $helpmeplease = 0; my $apply_patches = 1; my $revoke_list = ""; my $force_owner = ""; my $config_file = "config/administration.yml"; my $dbadir = $0; $dbadir =~ s#/[^/]*$##; my $patchdir = $dbadir . "/patches"; # # We look in a few places for the config file. First relative to # where we are, then relative to the code we are running, then we # start to look in absolute locations. Then we give up :-) if ( ! -f $config_file ) { $config_file = $0; $config_file =~ s{[^/]+/update-[a-z]+-database}{config/administration.yml}; } if ( ! -f $config_file ) { $config_file = "/etc/davical/administration.yml"; } if ( -f $config_file ) { use YAML qw( LoadFile ); my ($ycfg) = LoadFile($config_file); $dbuser = $ycfg->{'admin_db_user'} if ( defined($ycfg->{'admin_db_user'})); $dbpass = $ycfg->{'admin_db_pass'} if ( defined($ycfg->{'admin_db_pass'})); $dbhost = $ycfg->{'admin_db_host'} if ( defined($ycfg->{'admin_db_host'})); $dbname = $ycfg->{'admin_db_name'} if ( defined($ycfg->{'admin_db_name'})); $dbport = $ycfg->{'admin_db_port'} if ( defined($ycfg->{'admin_db_port'})); $appuser = $ycfg->{'app_db_user'} if ( defined($ycfg->{'app_db_user'})); } GetOptions ('debug!' => \$debug, 'dbname=s' => \$dbname, 'dbuser=s' => \$dbuser, 'dbpass=s' => \$dbpass, 'dbport=s' => \$dbport, 'dbhost=s' => \$dbhost, 'appuser=s' => \$appuser, 'patch!' => \$apply_patches, 'owner=s' => \$force_owner, 'revoke=s' => \$revoke_list, 'help' => \$helpmeplease ); show_usage() if ( $helpmeplease ); $revoke_list = ", ". $revoke_list if ( $revoke_list ne "" ); ############################################################ # Open database connection. Note that the standard PostgreSQL # environment variables will also work with DBD::Pg. ############################################################ my $dsn = "dbi:Pg:dbname=$dbname"; $dsn .= ";host=$dbhost" if ( "$dbhost" ne "" ); $dsn .= ";port=$dbport" if ( $dbport != 5432 ); print "Using database: $dbuser".'%'.$dbpass.'@'.$dsn."\n" if ( $debug ); my $pg_version = get_postgresql_version(); my $current_revision; my $last_results = ''; # Will hold the last SQL result from applying a patch if ( $apply_patches ) { $current_revision = get_current_revision(); printf( "The database is version %.1lf currently at revision %d.%d.%d.\n", $pg_version, $current_revision->{'schema_major'}, $current_revision->{'schema_minor'}, $current_revision->{'schema_patch'} ); opendir( PATCHDIR, $patchdir ) or die "Can't open patch directory $patchdir"; my @patches = grep { /^([0-9]+)\.([0-9]+)\.([0-9]+)([a-z]?)\.sql$/ } readdir(PATCHDIR); closedir(PATCHDIR); @patches = sort { compare_revisions(revision_hash($a),revision_hash($b), 1); } @patches; my $applied = 0; for ( my $i=0; $i <= $#patches; $i++ ) { printf( "Looking at patches[%d] (%s)\n", $i, $patches[$i]) if ( $debug ); if ( compare_revisions(revision_hash($patches[$i]),$current_revision) > 0 ) { print "Applying patch $patches[$i] ... "; if ( !apply_patch( $patches[$i] ) ) { # Skip to the end unless the next patch is an alternate for the same version. if ( defined($patches[$i+1]) && compare_revisions(revision_hash($patches[$i]),revision_hash($patches[$i+1])) == 0 ) { print "failed. Attempting next alternative.\n"; $applied--; } else { print "failed!\n$last_results ==> No further patches will be attempted!\n"; last; } } else { print "succeeded.\n"; } $applied++; } else { print "Patch $patches[$i] has already been applied.\n" if ( $debug ); } } if ( $applied ) { print "Successfully applied $applied patches.\n"; } else { print "No patches were applied.\n"; } } # Ensure the locales data is up to date apply_sql_file( $dbadir, "supported_locales.sql" ); print "Supported locales updated.\n"; # update any views apply_sql_folder( $dbadir, 'views', "Updated view: " ); # Ensure the functions are up to date apply_sql_file( $dbadir, "caldav_functions.sql" ); print "CalDAV functions updated.\n"; if ( $pg_version >= 8.3 ) { apply_sql_file( $dbadir, "rrule_functions.sql" ); } else { apply_sql_file( $dbadir, "rrule_functions-8.1.sql", ); } print "RRULE functions updated.\n"; # Ensure the permissions are up to date apply_permissions( $dbadir, "appuser_permissions.txt" ); print "Database permissions updated.\n"; # The End! exit 0; ############################################################ # Revision Hash - we either have a single parameter, # which is of the form "1.2.3" or we have three parameters. ############################################################ sub revision_hash { my $rev = +{ 'schema_major', => 0, 'schema_minor' => 0, 'schema_patch' => 0, 'alternative' => '0' }; my $first = shift; return $rev unless ( defined($first) ); if ( $first =~ /^([0-9]+)\.([0-9]+)\.([0-9]+)([a-z]?)([^0-9]|$)/ ) { $rev->{'schema_major'} = $1; $rev->{'schema_minor'} = $2; $rev->{'schema_patch'} = $3; $rev->{'alternative'} = $4; } else { $rev->{'schema_major'} = $first; $rev->{'schema_minor'} = shift; $rev->{'schema_patch'} = shift; $rev->{'alternative'} = '0'; } return $rev; } ############################################################ # Compare revisions ############################################################ sub compare_revisions { my $a = shift; my $b = shift; my $test_alt = shift; return -1 if ( $a->{'schema_major'} < $b->{'schema_major'} ); return 1 if ( $a->{'schema_major'} > $b->{'schema_major'} ); return -1 if ( $a->{'schema_minor'} < $b->{'schema_minor'} ); return 1 if ( $a->{'schema_minor'} > $b->{'schema_minor'} ); return -1 if ( $a->{'schema_patch'} < $b->{'schema_patch'} ); return 1 if ( $a->{'schema_patch'} > $b->{'schema_patch'} ); if ( defined($test_alt) ) { return -1 if ( $a->{'alternative'} lt $b->{'alternative'} ); return 1 if ( $a->{'alternative'} gt $b->{'alternative'} ); } return 0; } ############################################################ =item folder_ordering() Function to allow us to sort folders which may have a number prefix. =cut ############################################################ sub folder_ordering { my $a = shift; my $b = shift; my $numeric_a = 999999; my $numeric_b = 999999; if ( $a =~ m{^(\d+)-} ) { $numeric_a = $1; } if ( $b =~ m{^(\d+)-} ) { $numeric_b = $1; } return -1 if ( $numeric_a < $numeric_b ); return 1 if ( $numeric_a > $numeric_b ); # Fall back on alphanumeric comparison return -1 if ( $a lt $b ); return 1 if ( $a lt $b ); return 0; } ############################################################ # Get the current version of PostgreSQL ############################################################ sub get_postgresql_version { my $dbh = DBI->connect($dsn, $dbuser, $dbpass, { AutoCommit => 0 } ) or die "Can't connect to database $dbname"; my $current_version = $dbh->prepare( <errstr; SELECT regexp_replace( split_part( version(), ' ', 2), E'\.[0-9]\$', '') EOQ if ( $current_version->execute() ) { my $version = $current_version->fetchrow_arrayref(); undef $current_version; $dbh->disconnect; $version->[0] =~ s/\D+$//; # It seems the regex in SQL is broken in 8.1, at least on CentOS 5.3 return $version->[0]; } else { die "ERROR: Cannot read current revision from database."; } } ############################################################ # Get the current revision ############################################################ sub get_current_revision { my $dbh = DBI->connect($dsn, $dbuser, $dbpass, { AutoCommit => 0 } ) or die "Can't connect to database $dbname"; my $current_revision = $dbh->prepare( <errstr; SELECT schema_major, schema_minor, schema_patch FROM awl_db_revision ORDER BY schema_id DESC LIMIT 1 EOQ if ( $current_revision->execute() ) { my $revision = $current_revision->fetchrow_hashref(); undef $current_revision; $dbh->disconnect; return $revision; } else { die "ERROR: Cannot read current revision from database."; } } ############################################################ # Apply a DB Patch File ############################################################ sub apply_patch { my $patch = shift; apply_sql_file( $patchdir, $patch ); $current_revision = get_current_revision(); if ( compare_revisions($current_revision,revision_hash($patch)) != 0 ) { printf( "Failed to apply revision %s to the database!\n", $patch ) if ( $debug ); return 0; } return 1; # Success } ############################################################ # Apply SQL File # Note that this stuffs the password into an environment # variable, which isn't ideal. If you use a .pgpass you # can bypass that issue, but you still need it on the command # line for this program until I get a patch from someone. ############################################################ sub apply_sql_file { my $sqldir = shift; my $sqlfile = shift; my @psql_opts = ( "psql", "-q", "-f", $sqldir."/".$sqlfile ); push @psql_opts, "-h", $dbhost if ( $dbhost ne "" ); push @psql_opts, "-p", "$dbport" if ( $dbport != 5432 ); push @psql_opts, "-U", $dbuser if ( $dbuser ne "" ); push @psql_opts, $dbname; # It seems that FreeBSD has a poorer argument parsing library so non-option arguments must be last $ENV{'PGPASS'} = $dbpass if ( $dbpass ne "" ); my $command = join ' ', @psql_opts; $last_results = `$command 2>&1 1>/dev/null`; $last_results =~ s/^.*WARNING: there is no transaction in progress\s$//m; $last_results =~ s/^.*NOTICE: //m; } ############################################################ =item apply_sql_folder Applies the SQL files in a folder in order, with some magic to apply specifically versioned ones by preference. =cut sub apply_sql_folder { my $dbadir = shift; my $folder_name = shift; my $announce_prefix = shift; my $folder = $dbadir . '/' . $folder_name; opendir( FOLDER, $folder ) or die "Can't open SQL directory $folder"; my @sql_files = grep { /^[^.].*\.sql$/ } readdir(FOLDER); closedir(FOLDER); @sql_files = grep( !/-\d+.\d+\.sql$/, @sql_files); @sql_files = sort { folder_ordering($a,$b); } @sql_files; for ( my $i=0; $i <= $#sql_files; $i++ ) { my $apply_file = $sql_files[$i]; my $testfile = $folder . '/' . $apply_file; $testfile =~ s{\.sql$}{-$pg_version.sql}; $apply_file = $testfile if ( -f $testfile ); apply_sql_file( $folder, $apply_file ); print $announce_prefix, $apply_file, " applied.\n"; } } ############################################################ # Apply database permissions from file ############################################################ sub apply_permissions { my $sqldir = shift; my $permsfile = shift; open PERMS, '<', $sqldir."/".$permsfile; my $dbh = DBI->connect($dsn, $dbuser, $dbpass, { AutoCommit => 1 } ) or die "Can't connect to database $dbname"; my $sql; my $current_grant; while( ) { next if ( /^\s*(#|--)/ ); /^\s*GRANT\s+(\S.*)\s*$/i && do { $current_grant = $1; }; /^\s*ON\s+(\S.*)\s*$/i && do { defined($current_grant) or die "No GRANT before ON in $permsfile\n"; my $doohickey = $1; if ( $revoke_list ne "" ) { # TODO: we should really loop through the revoke_list so that a single non-existent # user doesn't cause this whole statement to fail. $sql = sprintf( "REVOKE ALL ON %s FROM %s %s", $doohickey, $appuser, $revoke_list ); print $sql, "\n" if ( $debug ); $dbh->do($sql); } $sql = sprintf( "GRANT %s on %s to %s", $current_grant, $doohickey, $appuser ); print $sql, "\n" if ( $debug ); $dbh->do($sql); if ( $force_owner ne "" ) { if ( $doohickey =~ /_seq$/ ) { $sql = sprintf( "GRANT ALL on %s to %s", $doohickey, $force_owner ); } else { $sql = sprintf( "ALTER TABLE %s OWNER to %s", $doohickey, $force_owner ); } print $sql, "\n" if ( $debug ); $dbh->do($sql); } }; } close(PERMS); $dbh->disconnect; } ############################################################ # Tell the nice user how we do things. Short and sweet. ############################################################ sub show_usage { print <