#=============================================================================
# Doloforge::Command::Patch : doloforge database patching command-line tool
#
# This module runs SQL scripts and similar depending on a client/project
# keeping track of what was run. The goal is to run it manually on the devl
# stage, while having the publish process run it on the other stages.
#
# Full help :
#   doloforge patch --man
#=============================================================================


package Doloforge::Command::Patch;

use strict;

# This base class implements the constructor and help methods in a generic way.
use base qw(Doloforge::Command);

use IO::Handle;
use File::Path;
use File::Basename;
use File::Touch;


#=============================================================================
=encoding utf8

=head1 NAME

Doloforge::Command::Patch - doloforge database patching command-line tool

=head1 SYNOPSIS

    # Runs all the pending patches on the current stage (ie. devl)
    doloforge patch --run --client internal --project sandbox
    
    # The --test option just check which scripts would be run

=head1 DESCRIPTION

Todo

=cut


#=============================================================================
# Method getOptions
#
# Accepted arguments from the command-line, in the form accepted by Getopt::Long.
# Options with no value that match method names of that package will be called as
# methods, with the decoded options as parameters.

sub getOptions {
    return (
        # actions :
        'run',                  # run all the pending patches
        # parameters :
        'client|c=s',           # the client/client pair to run the patches...
        'project|p=s',          # ...on all DB systems configured for it...
        'test|t',               # test the patches to run only / do not run them
        # TODO 'name|n=s',      # name of a single patch to run, or all the patches if the option is not present
    );
}


#=============================================================================
# Method run
#
# This is the main goal of the command :
#   - identifies all patches associated with the project
#       * for all configured DBs (sqlserver, postgresql, mysql)
#       * find the patch/*/ dirs
#       * in there, select
#           + either the patch.* executable files (as seen on e-dition projects)
#           + or all executable files, in alphanumerical order
#           + or all *.sql files, in alphanumerical order
#   - for those that have no or older _[scriptname].log log file
#   - run the scripts in order
#   - stop on any script with return code not 0
#   - scripts output is simply written on the terminal
#   - this is meant to be run by the publish command, on the test and prod stages (publish will store the output in a file)

sub run {
    my $self = shift;
    my ($option) = @_;

    # Get all identification from the options
    my ($client_name, $project_name) = ($option->{client}, $option->{project});

    # FIXME : find a cleaner way to retreive a project's or client's config (Doloforge::Object::Factory ???)
    my $project = (&Doloforge::Command::Factory('Project', $self->{global}))->_list('active', $client_name, $project_name)->{"$client_name/$project_name"};
    #print Data::Dumper->Dump([$project], [qw(project)]);
    $self->error("Client/project $client_name/$project_name does not exist") unless ($project);

    # Select the DB services that can run patches, and exist in the said project
    foreach my $service ('sqlserver', 'mysql_TODO', 'postgres7_TODO') {
        if (exists($project->{services}->{$service})) {

            # Find the patches/*/ dirs, then select the proper patch script
            my $patch_base = "$self->{global}->{dir}->{shared}/$client_name/$project_name/$service/patches";
            $self->message("Searching for patches in $patch_base");
            my $sub = "_patch_$service";

            # FIXME : glob() is sensitive to spaces : get rid of that...
            # FIXME : don't take dirs/files starting with "_" into account
            foreach my $patch_dir (sort(grep {-d} glob("$patch_base/*"))) {
                # ...either the patch.* executable files (as seen on e-dition projects)
                my @scripts = grep {-x} glob("$patch_dir/patch.*");
                # ...or all executable files, in alphanumerical order
                @scripts = grep {-x} glob("$patch_dir/*") unless ($#scripts >= 0);
                # ...or all *.sql files, in alphanumerical order
                @scripts = glob("$patch_dir/*.sql") unless ($#scripts >= 0);

                # For those that have no or older _[scriptname].log log file,
                # run the scripts in order...
                foreach my $script (sort(@scripts)) {
                    my $log = dirname($script).'/_'.basename($script).'.log';
                    if ((stat($script))[9] > (stat($log))[9]) {

                        # Run the script, and stop on any script with return code not 0
                        $self->message(($option->{test} ? 'Dry-running' : 'Running')." patch $script");
                        my $rc = 0;
                        # If the --test option was given, do not actually run the scripts...
                        unless ($option->{test}) {
                            if (-x $script) {
                                # If it's an executable file, run it as an external command
                                $rc = $self->_run_shell($project, $script, $log);
                            } else {
                                # If it's an *.sql file, run it with the proper SQL shell tool
                                $rc = $self->$sub($project, $script, $log);
                            }
                        }
                        return unless ($rc == 0);
                    } else {
                        $self->info("Not running patch $script");
                    }
                }
            }
        }
    }
}


#=============================================================================
# Implement the patch system for the MS SQL Server database system.
# This method runs one script, and returns the return code from the process.
# It receives the full project config, the full script name and full log file name.
# Output from the script is written in the log file and on the terminal.

sub _patch_sqlserver {
    my $self = shift;
    my ($project, $script, $log) = @_;
    my @command = ();
    my $elapsed = time;

    # This script runs the SQL file within the following context :
    #   sqsh -H $DSQUERY -D $DSDATABASE -U doloforge -P **** -i patch.sql -a 1
    #   - the "doloforge" SQL Server administrator is used to run the script (-U, -P)
    #   - the script must stop after the first error (-a 1)

    # Retreive the project_env contents if it was not already loaded
    $self->{project_env} ||= Config::YAML->new(config => "$self->{global}->{dir}->{shared}/$project->{client}/$project->{name}/_conf/project_env.yaml");

    # Prepare the base sqsh command (man sqsh(1))
    #   -S server      The name of the Sybase server to connect, the default of this is the external environment variable $DSQUERY.
    #   -D database    Causes sqsh to attempt to start with your database context set to database rather than your default database (usually master).
    #   -U username    The Sybase username to connect to the database as, this defaults to the username of the user running sqsh.
    #   -a count       Sets the maximum count of failures (as determined by the $thresh_fail variable) that may occur before sqsh will abort. Setting this to 0 indicates that sqsh should not exit on errors.
    #   -i filename    Read all input from filename rather than from stdin.
    push(@command, 'sqsh', '-S', $self->{project_env}->{DSQUERY}, '-D', $self->{project_env}->{DSDATABASE}, '-U', 'doloforge', '-a', '1', '-i', $script);
    # sqsh needs the DSQUERY environment value... don't ask me why (it must be related to the freetds library)...
    $ENV{DSQUERY} = $self->{project_env}->{DSQUERY};

    $self->info("Running ".join(' ', @command)." with output to $log");

    # Redirects all output to the local log file (this is a bash syntax)
    # Save current STDOUT/ERR for future use
    open(SAVEOUT, '>&STDOUT') or die("Can't dup STDOUT: $!");
    open(SAVEERR, '>&STDERR') or die("Can't dup STDERR: $!");

    # Fork a tee command for the job output, to follow in the terminal AND save the output.
    # Do not append to the file (tee -a), since appending en empty output will not change the file contents...
    open(STDOUT, '|-', 'tee', $log) or die("Can't pipe to tee: $!");
    open(STDERR, '>&STDOUT') or die("Can't dup STDERR to STDOUT: $!");
    STDOUT->autoflush(); # set autoflush to make sure the command output is not buffered too long

    # ...then actually run the job... adding the sensitive password at the latest time, and removing it afterwards...
    push(@command, '-P', $self->{global}->{sqshpass});
    my $rc = system(@command);
    $#command -= 2;

    # Get back to the previous STDOUT/ERR, since we must report to the administrator just below...
    open(STDOUT, '>&SAVEOUT') or die("Can't undup STDOUT: $!");
    open(STDERR, '>&SAVEERR') or die("Can't undup STDERR: $!");

    # Test for return code et al, and report to the running developer
    if ($rc == -1) {
        $self->warn("Failed to execute $command[0]: $!");
    } elsif ($rc & 127) {
        $self->warn("Child $command[0] died with signal ".($rc & 127).", ".(($rc & 128) ? 'with' : 'without')." coredump");
    } elsif ($rc >= 256) {
        $self->warn("Child $command[0] exited with value ".($rc >> 8));
    } else {
        $self->info("Finished ".join(' ', @command)." rc=$rc elapsed=".(time - $elapsed));
    }

    return $rc;
}


#=============================================================================
# Implement the patch system for the simple shell executable scripts.
# TODO : run the script...

sub _run_shell {
    my $self = shift;
    my ($project, $script, $log) = @_;
    my @command = ();

    touch($log);

    return 0;
}



1;
__END__

=head1 AUTHOR

Nicolas Huillard <F<nicolas@huillard.net>>, Dolomède

=head1 SEE ALSO

doloforge(1)

=cut
