#=============================================================================
# Doloforge::Command::Publish : doloforge publishing command-line tool
#
# This module publishes client/project from stage to stage, executing various
# plug-ins in the process.
#
# Full help :
#   doloforge publish --man
#
# Quick test :
#   perl ./src/doloforge --config ./t/doloforge.yaml publish --client internal --project sandbox --destination sample --dry-run
#=============================================================================


package Doloforge::Command::Publish;

use strict;

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

use File::Path;
use IO::Handle;


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

=head1 NAME

Doloforge::Command::Publish - doloforge publishing command-line tool

=head1 SYNOPSIS

    # Publishes the "sandbox" project of the client "internal", from the "devl" stage to the next one...
    doloforge publish --client internal --project sandbox --source devl --dry-run
    doloforge publish --client internal --project sandbox --source devl --do-it

    # You must have an authorized SSH key in order to publish a project
    # Generate your key using the following command :
    ssh-keygen -N ""
    # Ask the administrator to add your public key to the configuration :
    cat /home/users/[yourlogin]/.ssh/id_rsa.pub >> /etc/doloforge/keys/client.[clientid].authorized_keys

    # Mirrors a project from the "prod" stage to the local "mirror" install
    doloforge publish --client internal --project sandbox --destination mirror --dry-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 :
        'dry-run',              # test the publishing, without doing anything
        'do-it',                # actually do the publishing
        # parameters :
        'client|c=s',           # the client to publish the project
        'project|p=s',          # the project belonging to the client
        'lib|l',                # ...or the lib directory to send
        'source|s=s',           # the source stage to publish from (the destination stage is defined by the global configuration)
        'destination|d=s',      # the destination stage, if you prefer to refer to this instead of --source...
    );
}


#=============================================================================
# Method do_it, dry_run
#
# These match the --do-it or --dry-run commands, that trigger the publishing process,
# each one with its own (obvious) options.

sub do_it {
    my $self = shift;

    my ($option) = @_;
    $self->_publish(1, $option);
}

sub dry_run {
    my $self = shift;

    my ($option) = @_;
    $self->_publish(0, $option);
}


#=============================================================================
# Private methods
#
# Actually publish the project, identifying the stage, pre- and post-plug-ins, etc.

sub _publish {
    my $self = shift;
    my ($do_it, $option) = @_;

    # Get all identification from the options
    my ($client, $project, $source, $destination) = ($option->{client}, $option->{project}, $option->{source}, $option->{destination});
    my $lib = exists($option->{lib});
    # The default stage of the host is taken as the source if none is provided
    $source = $self->{global}->{stages}->{$destination}->{publish}->{previous} if (not $source and $destination);
    $source = $self->{global}->{stage} unless $source;

    # If any of the components is missing, just stop there
    $self->error("Missing one of --client and, --project or --lib, exclusive") unless ($client and ($project xor $lib));
    $self->error("Missing --source or bad cluster configuration") unless ($source);

    # Check the client/project or client/lib for existence
    my $src_dir = ($self->{global}->{stages}->{$source}->{dir}->{shared} or $self->{global}->{dir}->{shared})."/$client/".($lib ? 'lib' : $project);
    $self->error("Client $client ".($lib ? 'lib' : "project $project")." base directory does not exist $src_dir") unless (-d $src_dir);

    # Get the destination stage from the source stage in the configuration
    $destination = $self->{global}->{stages}->{$source}->{publish}->{next} unless ($destination);
    $self->error("Cannot identify destination stage from the cluster configuration") unless ($destination);

    # Identify the destination directory within the destination stage
    my $dst_dir = ($self->{global}->{stages}->{$destination}->{dir}->{shared} or $self->{global}->{dir}->{shared})."/$client/".($lib ? 'lib' : $project);

    # The protocol to use is defined by the destination stage configuration in the publish pair
    my $publish_conf = $self->{global}->{stages}->{$source}->{publish}->{destinations}->{$destination};
    my $sub = "_publish_$publish_conf->{protocol}";
    $self->error("The $source to $destination publishing pair is not configured (missing or wrong protocol '$publish_conf->{protocol}')") unless ($publish_conf->{protocol} and $self->can($sub));

    # Prepare the logging directory and email sending
    my ($year, $month, $day, $hour, $min, $sec) = (localtime())[5,4,3,2,1,0];
    my $log_base = "$self->{global}->{dir}->{shared}/$client/lib/_stats/$self->{name}";
    my $log_tag = "${project}_from_${source}_to_${destination}".($do_it ? '' : '_dry-run');
    my $log_dir = "history/".sprintf('%4d/%02d/%02d/%02d:%02d:%02d', $year + 1900, $month + 1, $day, $hour, $min, $sec)."_$log_tag";
    mkpath("$log_base/$log_dir");
    # Add a symlink to point directly to the lastest source/destination/dry-run report (much like log analysis reports)
    if (-e "$log_base/$log_tag") {
        unlink("$log_base/$log_tag")
            or $self->warn("Can't remove '$log_base/$log_tag': $!");
    }
    symlink($log_dir, "$log_base/$log_tag")
        or $self->warn("Can't create symbolic link '$log_base/$log_tag' to '$log_dir': $!");

    # Do publish now, using the said publishing protocol
    $self->info(($do_it ? "Publishing" : "Dry-run publishing")." $client/".($lib ? 'lib' : $project).", from $source to $destination, using $publish_conf->{protocol} to $publish_conf->{rsyncdest}, user ".getpwuid($>));
    $self->$sub(
        do_it => $do_it,
        client => $client,
        project => $project,
        lib => $lib,
        source => $source,
        destination => $destination,
        src_dir => $src_dir,
        dst_dir => $dst_dir,
        log_dir => "$log_base/$log_dir",
        publish_conf => $publish_conf,
    );
}


# Implement the rsync over SSH publishing protocol
# This is the standard publishing method inside a doloforge install : devl to test, test to prod

sub _publish_rsync_over_ssh {
    my $self = shift;
    my (%option) = @_;
    my @command = ();
    my $elapsed = time;

    # This protocol runs rsync from the source stage to the destination one, much like :
    #   ssh internal@nfs.test.doloforge.net rsync --dry-run --rsh=ssh --archive --verbose --delete --exclude='_*' /srv/doloforge/shared/internal/fallback/ internal@nfs.prod.doloforge.net:/srv/doloforge/shared/internal/fallback/ 2>&1 | tee /tmp/rsync.log | grep -v -e '/$' -e '^rsync: failed to set times on '
    # Out format comparison :
    #   '%i %n%L %l'        <f.st...... /php5/htdocs/scores.php 5606
    #   '%i %n%L %l %M'     <f.st...... /php5/htdocs/scores.php 5606 2009/04/09-08:30:45
    #   '%i /%f%L %l %M'    <f.st...... /srv/doloforge/shared/idylyk/riremedecin/php5/htdocs/scores.php 5606 2009/04/09-08:30:45
    # The first one usually fits pasted in text-only email, which is fine. It just lacks the full path for easy checking.
    # File timestamps is good when publishing from test to prod, where developers cannot easily list the files.
    # The last /%f needs to change to the source directory, because rsync "deleting" log messages are wrongly based on current dir
    #   chdir($shared) or die("Can't chdir to '$shared': $!");
    # TODO : add timezone to the timestamps

    # Remotely connect to the rsyncsource host to issue the rsync command, or just run it locally
    push(@command, 'ssh', "$option{client}\@$option{publish_conf}->{rsyncsource}") if (exists($option{publish_conf}->{rsyncsource}));
    # Add the rsync command itself, with Doloforge options and filter rules (project-specific rules files + system-wide rules file)
    push(@command, 'rsync', '--rsh=ssh', '--stats', '--archive', '--delete', '--omit-dir-times', '--omit-link-times', '--one-file-system', '--sparse', '--out-format=%i %n%L %l %M'); # FIXME --checksum pour mason, php, etc.
    push(@command, "--filter=merge $option{src_dir}/conf/rsync_filter.conf") if (-f "$option{src_dir}/conf/rsync_filter.conf");
    push(@command, "--filter=merge $self->{global}->{dir}->{conf}/rsync_filter".($option{lib} ? '_lib' : '').".conf");
    push(@command, '--dry-run') unless ($option{do_it});
    # Finally, the path to publish, along with it's destination on the remote host
    # Paths must finish with a "/" to make sure rsync treats them as "everything in that directory"
    push(@command, "$option{src_dir}/", "$option{client}\@$option{publish_conf}->{rsyncdest}:$option{dst_dir}/");

    # Escape spaces and escape chars within each command argument...
    # ...if the rsync command is remotely executed using ssh, because ssh spawns a shell...
    map(s/([\\ ])/\\$1/g, @command) if (exists($option{publish_conf}->{rsyncsource}));

    my $log_file = "$option{log_dir}/rsync.log";
    $self->info("Running ".join(' ', @command)." with output to $log_file");

    # 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.
    # In --quiet mode (useful for cron jobs), just output to the log file, and not in the resulting email.
    if ($self->{global}->{verbose} >= 1) {
        open(STDOUT, '|-', 'tee', '-a', $log_file) or die("Can't pipe to tee: $!");
    } else {
        open(STDOUT, ">$log_file") or die("Can't open $log_file: $!");
    }
    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 print the details and actually run the job...
    print(($option{do_it} ? "Publishing" : "Dry-run publishing")." from $option{source}:$option{src_dir}/ to $option{destination}:$option{dst_dir}/ at ".localtime().", user ".getpwuid($>)."\n");
    print("Logging to $log_file\n\n");
    my $rc = system(@command);

    # 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));
    }
}


# Implement the reverse rsync over SSH mirorring protocol.
# This is the method used to create a mirror of the prod stage. It "pulls" data
# (which differs from the previous method, which "pushes") from the server
# rsync connects to, to the server the publish command is run on.

sub _publish_rsync_reverse_over_ssh {
    my $self = shift;
    my (%option) = @_;
    my @command = ();
    my $elapsed = time;

    # This protocol runs rsync from the source stage to the destination one, much like :
    #   rsync --dry-run --rsh='ssh -i /etc/doloforge/keys/client.internal.id_rsa' --stats --archive --delete --omit-dir-times --one-file-system --sparse --out-format='%i %n%L %l' --compress --filter='merge /etc/doloforge/rsync_filter.conf' internal@devl.doloforge.com:/srv/doloforge/shared/internal/fallback/ /srv/doloforge/shared/internal/fallback/

    # Remotely connect to the rsyncdest host to issue the rsync command, or just run it locally
    push(@command, 'ssh', "$option{client}\@$option{publish_conf}->{rsyncdest}") if (exists($option{publish_conf}->{rsyncdest}));
    # Add the rsync command itself, with Doloforge options and filter rules (project-specific rules files + system-wide rules file)
    push(@command, 'rsync', '--rsh=ssh', '--stats', '--archive', '--delete', '--omit-dir-times', '--omit-link-times', '--one-file-system', '--sparse', '--out-format=%i %n%L %l %M', '--compress'); # FIXME --checksum pour mason, php, etc.
    push(@command, "--filter=merge $option{dst_dir}/conf/rsync_reverse_filter.conf") if (-f "$option{dst_dir}/conf/rsync_reverse_filter.conf");
    push(@command, "--filter=merge $self->{global}->{dir}->{conf}/rsync_reverse_filter".($option{lib} ? '_lib' : '').".conf");
    push(@command, '--dry-run') unless ($option{do_it});
    # Finally, the path to publish, along with it's destination on the remote host
    # Paths must finish with a "/" to make sure rsync treats them as "everything in that directory"
    # FIXME : modifier /etc/doloforge/doloforge.mirror.yaml pour stages:mirror:prod:destinations:mirror:rsyncsource, et ajouter stages:mirror:prod:destinations:mirror:shared
    push(@command, "$option{client}\@$option{publish_conf}->{rsyncsource}:$option{src_dir}/", "$option{dst_dir}/");

    # Escape spaces and escape chars within each command argument...
    # ...if the rsync command is remotely executed using ssh, because ssh spawns a shell...
    map(s/([\\ ])/\\$1/g, @command) if (exists($option{publish_conf}->{rsyncdest}));

    my $log_file = "$option{log_dir}/rsync_reverse.log";
    $self->info("Running ".join(' ', @command)." with output to $log_file");

    # 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.
    # In --quiet mode (useful for cron jobs), just output to the log file, and not in the resulting email.
    if ($self->{global}->{verbose} >= 1) {
        open(STDOUT, '|-', 'tee', '-a', $log_file) or die("Can't pipe to tee: $!");
    } else {
        open(STDOUT, ">$log_file") or die("Can't open $log_file: $!");
    }
    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 print the details and actually run the job...
    print(($option{do_it} ? "Publishing" : "Dry-run publishing")." from $option{source}:$option{src_dir}/ to $option{destination}:$option{dst_dir}/ at ".localtime().", user ".getpwuid($>)."\n");
    print("Logging to $log_file\n\n");
    my $rc = system(@command);

    # 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));
    }
}



1;
__END__

=head1 AUTHOR

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

=head1 SEE ALSO

doloforge(1)

=cut
