#!/usr/bin/perl
#
# Written by Adam Lewenberg <adamhl@stanford.edu>
# Copyright 2014, 2017
#     The Board of Trustees of the Leland Stanford Junior University
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

## no critic (RequireBracedFileHandleWithPrint)
## no critic (ProhibitParensWithBuiltins)

use strict ;
use warnings ;
use Carp ;
use Config::Tiny ;
use DBI ;
use Getopt::Long::Descriptive ;
use Pod::Usage ;
use Data::Dumper ;
use IPC::Cmd ;
use JSON ;

my $DEBUG ;

# The path to the "puppet query" configuration file.
# This can be set via option or command-line option.
# See also ???
my $PUPPET_QUERY_CONFIG_PATH ;

sub progress {
    my ($msg) = @_ ;
    if ($DEBUG) {
        print $msg . "\n" ;
    }
    return ;
}

#<<< perltidy does not like this formatting, but we do
my ($opt, $usage) = describe_options(
    'pdb-backend %o <action> [<query>]',
    [ 'verbose|v',   'print extra stuff'],
    [ 'help|h',      'print usage message and exit', {'shortcircuit' => 1}],
    [ 'manual',      'print manual page and exit',   {'shortcircuit' => 1}],
    [ 'config|c=s',  'path to puppet query configuration file',
                        {'default' => '/etc/puppetdb_cli.conf'}],
);
#>>>

if ($opt->help()) {
    help() ;
    exit 0 ;
}

if ($opt->manual) {
    pod2usage(-verbose => 2) ;
    exit 0 ;
}

if ($opt->verbose) {
    $DEBUG = 1 ;
}

# Set config path from command-line option but overwrite
# with environment variable. In other words, the environment
# variable wins.
if ($opt->config) {
    $PUPPET_QUERY_CONFIG_PATH = $opt->config;
}
if ($ENV{'PUPPET_QUERY_CONFIG_PATH'}) {
    $PUPPET_QUERY_CONFIG_PATH = $ENV{'PUPPET_QUERY_CONFIG_PATH'} ;
}


my %ALLOWED_ACTIONS = (
    'help'          => \&help,
    'manual'        => \&man_page,
    'listfacts'     => \&list_facts,
    'listnodes'     => \&list_nodes,
    'listnodes-raw' => \&list_nodes_raw,
    'query'         => \&query_use_arg,
    'delnode'       => \&delnode,
) ;


# listfacts:      direct Puppet SQL query
# listnodes:      puppet query <something>
# list-nodes-raw: puppet query <something>
# query_use_arg:  puppet query <something>
# delnode:        puppet node purge FQDN

# There should be either one or two arguments. If no arguments, print help
# and exit. If more than two, exit with an error.
my ($ACTION, $QUERY) ;

my $number_of_args = scalar(@ARGV) ;
if ($number_of_args == 0) {
    help() ;
    exit 0 ;
} else {
    $ACTION = lc($ARGV[0]) ;

    if (!(exists($ALLOWED_ACTIONS{$ACTION}))) {
        exit_with_error("unrecognized action '$ACTION'") ;
    }

    if ($number_of_args == 1) {

        # If the action is node or facts, thrown an error
        if ($ACTION eq 'query') {
            exit_with_error("action '$ACTION' missing its query string") ;
        }
    } elsif ($number_of_args == 2) {
        $QUERY = $ARGV[1] ;
    } else {
        exit_with_error('too many arguments') ;
    }
}

# Call the action
$ALLOWED_ACTIONS{$ACTION}->() ;

########################################################################################

# The help function
sub help {
    print($usage->text) ;

    my $topic = undef ;
    my $name  = 'pdb' ;

    ## ###    ## ###    ## ###    ## ###    ## ###    ## ###    ## ###
    my $help_help = <<"EOQ" ;

where <action> is one of:
  help                 Print this help summary
  manual               Print full man page
  listnodes            List all Puppet nodes
  listnodes-raw        List all Puppet nodes without trying to parse JSON
  listfacts            List all Puppet facts
  query <query>        Return facts for nodes matching <query>
  delnode <fqdn>       Delete <fqdn> from PuppetDB

EOQ
    ## ###    ## ###    ## ###    ## ###    ## ###    ## ###    ## ###
    print $help_help ;
    return ;
}

sub man_page {
    pod2usage(-verbose => 2) ;
    exit 0 ;
}

sub list_nodes {
    if ($QUERY) {
        exit_with_error('listnodes takes no additional arguments or options') ;
    }
    return list_nodes_aux(0) ;
}

sub list_nodes_raw {
    if ($QUERY) {
        exit_with_error('listnodes takes no additional arguments or options') ;
    }
    return list_nodes_aux(1) ;
}

sub list_nodes_aux {
    my ($raw) = @_ ;

    my ($stdout, $stderr, $rv) =
      run_puppet_query_command('nodes', q/[certname]{ certname ~ "." }/) ;

    if ($raw) {
        print $stdout ;
    } else {
        # Convert to a JSON object.
        my $json = JSON->new->allow_nonref;

        my $json_object = $json->decode($stdout);
        my @certnames = @{ $json_object } ;

        foreach my $certname_href (@certnames) {
            print $certname_href->{'certname'} . "\n" ;
        }
    }

    return ;
}

sub list_facts {
    if ($QUERY) {
        exit_with_error('listnodes takes no additional arguments or options') ;
    }

    my ($stdout, $stderr, $rv) = run_puppet_query_command('facts', '{ certname ~ "." }') ;

    print $stdout ;

    return ;
}

sub query_use_arg {

    # Is there a query? If not, exit with an error.
    if (!$QUERY) {
        exit_with_error('missing query') ;
    }

    my ($stdout, $stderr, $rv) = query($QUERY) ;

    print $stdout ;

    return ;
}

sub query {
    my ($query) = @_ ;

    # Is there a query? If not, exit with an error.
    if (!$query) {
        exit_with_error('missing query') ;
    }

    my @cmds = ('query') ;

    # Add in the config file options if $PUPPET_QUERY_CONFIG_PATH is set.
    if ($PUPPET_QUERY_CONFIG_PATH) {
        push(@cmds, '-c', $PUPPET_QUERY_CONFIG_PATH) ;
    }

    push (@cmds, qq[$query]) ;

    return run_puppet_command(@cmds) ;
}

sub delnode {
    my $fqdn = $QUERY ;

    if (!$QUERY) {
        exit_with_error(
            q{the 'delnode' action needs the FQDN of the node to delete}) ;
    }

    # Is there a node matching this FQDN?
    if (!node_exists($fqdn)) {
        if ($fqdn =~ m{^[[:lower:]\d_]+[.]stanford[.]edu}xsm) {
            exit_with_error("No Puppet node with FQDN '$fqdn' found") ;
        } else {
            exit_with_error("No Puppet node with FQDN '$fqdn' found"
                  . q{ (did you forget to append '.stanford.edu'?)}) ;
        }
    }

    # A node delete in Open Source Puppet is two steps:
    #
    # 1. node clean
    # 2. node decactivate

    my @cmd ;
    my ($stdout, $stderr, $rv) ;

    # 1. node clean
    @cmd = ('node', 'clean', $fqdn) ;
    ($stdout, $stderr, $rv) = run_puppet_command(@cmd) ;

    if ($stderr) {
        # If we see the error message that looks like
        # "Could not find files to clean for ..." that is OK as someone
        #  might have already cleaned the node's certificate.
        if ($stderr =~ m{Could\s*not\s*find\s*files\s*to\s*clean}ixsm) {
            print "info: it appears that ${fqdn}'s certificate has already been cleaned\n";
        } else {
            exit_with_error($stderr) ;
        }
    } else {
        print trim($stdout) ;
    }

    # 2. node deactivate
    @cmd = ('node', 'deactivate', $fqdn) ;
    ($stdout, $stderr, $rv) = run_puppet_command(@cmd) ;

    if ($stderr) {
        exit_with_error($stderr) ;
    } else {
        print trim($stdout) ;
    }

    print "\n" ;
    print "node '$fqdn' deleted from PuppetDB\n" ;
    return ;
}

# Return 1 if the node exists, 0 otherwise.
sub node_exists {
    my ($fqdn) = @_ ;

    my ($stdout, $stderr, $rv) = run_puppet_command(('node', 'status', $fqdn)) ;

    # If we get something back in $stdout this means the node exists.
    if (trim($stdout)) {
        return 1 ;
    } else {
        return 0 ;
    }

}

sub exit_with_error {
    my ($msg) = @_ ;
    print "error: $msg \n" ;
    exit(1) ;
}

# $action should be one of 'nodes' or 'facts'
sub run_puppet_query_command {
    my ($type, @commands) = @_ ;

    if ($type !~ m{nodes|facts}ixsm) {
        croak "unknown type '$type'" ;
    }

    my $query_command = trim("$type " . join(q{ }, @commands)) ;

    return query($query_command) ;
}

sub run_puppet_command {
    my (@commands) = @_ ;

    my @ruby_command = (
        'puppet',
        @commands,
    ) ;

    progress(q{about to run command '} . join(q{ }, @ruby_command) . q{'}) ;
    my ($stdout1, $stderr1, $rv1) = run_command(@ruby_command) ;
    if ($DEBUG) {
        progress("stdout: $stdout1") ;
        progress("stderr: $stderr1") ;
        progress("rc:     $rv1") ;
    }

    return ($stdout1, $stderr1, $rv1) ;
}

# Use: pass in an array, returns ($stdout, $stderr, $exit_value)
sub run_command {
    my (@command) = @_ ;

    # Try to use IPC::Run. If that is not available, try using
    # IPC::Open3.
    $IPC::Cmd::USE_IPC_OPEN3 = 1 ;

    # Add /opt/puppetlabs/bin to path or else things do not work properly.
    local $ENV{'PATH'} = $ENV{'PATH'} . ':/opt/puppetlabs/bin' ;

    my ($ok, $err, $full_buf, $stdout_buffer, $stderr_buffer) =
      IPC::Cmd::run(command => \@command) ;

    # Convert buffers to strings.
    my $stdout = join("\n", @{$stdout_buffer}) ;
    my $stderr = join("\n", @{$stderr_buffer}) ;

    # Try and extract error.
    my $return_code = 0 ;
    if ($err && ($err =~ m{exited[ ]with[ ]value[ ](\d+)$}xsm)) {
        $return_code = $1 ;
    }

    return ($stdout, $stderr, $return_code, $err) ;
}

sub trim {
    my ($x) = @_ ;

    if (!defined($x) || ($x eq q{})) {
        return $x ;
    } else {
        $x =~ s{^\s*}{}xsmg ;
        $x =~ s{\s*$}{}xsmg ;
        return $x ;
    }
}

########################################################################################

exit(0) ;

__END__

##############################################################################
# Documentation
##############################################################################

=for stopwords Lewenberg listnodes listfacts delnode certnames JSON
=for stopwords pdb backend PuppetDB PQL

=head1 NAME

pdb-backend - query and interact with PuppetDB using PQL

=head1 SYNOPSIS

B<pdb-backend> help

B<pdb-backend> manual

B<pdb-backend> [options] listnodes

B<pdb-backend> [options] listnodes-raw

B<pdb-backend> [options] listfacts

B<pdb-backend> [options] query <query>

B<pdb-backend> [options] delnode <fqdn>

=head1 DESCRIPTION

The main purpose for this script is to run PQL queries against the
PuppetDB database using C<puppetdb-cli>; see also
https://puppet.com/blog/introducing-puppet-query-language-pql and
https://github.com/puppetlabs/puppetdb-cli

=head2 B<help>

Display the short help string and exit.

=head2 B<manual>

Display man page and exit.

=head2 B<listnodes>

The B<listnodes> command lists the fully-qualified domain name of
every node in the Puppet DB.

=head2 B<listnodes-raw>

The B<listnodes-raw> command displays a list of node certnames in the
format that Puppet DB returns without attempting to parse. Use
B<listnodes-raw> when the B<listnodes> command gives you a JSON parse
error.

=head2 B<query>

Run a PQL query against the PuppetDB. For details on PQL, see
https://puppet.com/blog/introducing-puppet-query-language-pql

All results are returned as a JSON string. Use the C<jq> command-line
utility to parse the returned results. If you pipe to C<jq> and see an
error like "parse error: Invalid string: control characters from U+0000
through U+001F must be escaped" try piping first through C<tr>:

    $ pdb-backend query <query> | tr '\r\n' ' ' | jq '.'

To retrieve facts, use the B<facts> query. Each fact
is a four-tuple consisting of C<certname> (the server name),
C<environment> (e.g., "production"), C<name> (the
name of the fact), and C<value> (the value of the fact).
Note that C<certname> is usually fully-qualified.

If you need to descend into structured facts, use the
B<fact_contents> action.

You can also use the B<nodes> action to get Puppet-related information on
nodes.

=head3 OPTIONS

=over 4

=item B<-v|--verbose>

Show extra information useful when debugging script.

=item B<-c|--config> PATH

Specify the path to the puppetdb_cli configuration. Defaults to
B</etc/puppetdb_cli.conf>. If the environment variable
C<PUPPET_QUERY_CONFIG_PATH> is set then the value of
C<PUPPET_QUERY_CONFIG_PATH> will be used as the configuration file
path. Note that if the B<--config> option is used AND the
C<PUPPET_QUERY_CONFIG_PATH> environment variable is set, the environment
variable value "wins".

=back


=head3 EXAMPLES

    # Show all facts for host 'puppetservice6.stanford.edu'
    pdb-backend query 'facts { certname = "puppetservice6.stanford.edu" }'

    # Return the 'kernel' fact for all nodes
    pdb-backend query 'facts { name = "kernel" }'

    # Return the 'kernel' fact for host 'puppetservice6.stanford.edu'
    pdb-backend query 'facts { certname ~ "puppetservice6.stanford.edu" and name = "kernel" }'

    # Return just the value of the 'kernel' fact for host 'puppetservice6.stanford.edu'
    pdb-backend query 'facts[value] { certname ~ "puppetservice6.stanford.edu" and name = "kernel" }'

    # Return all nodes that whose major distro release number is "10".
    pdb-backend query 'fact_contents[certname] { (path ~> ["os", "distro", "release", "major"]) and (value = "10") }'

    # Return all nodes running in the "production" environment.
    pdb-backend query 'nodes[certname] { report_environment = "production" }'

    # Subqueries are supported.
    # Return all nodes running in the "production" environment whose
    # kernel is 4.1.12
    pdb-backend query 'nodes[certname] { report_environment = "production" and certname in facts[certname] {name = "kernelrelease" and value ~ "4.1.12"} }'

=head2 B<delnode>

To remove a node from the PuppetDB, use the C<delnode> command. The
argument to this command should be the full-qualified domain name of the
node to delete. This command does a "node clean" (remove certificates and
PuppetDB data) followed by a "node deactivate".

=head1 AUTHOR

Adam H. Lewenberg <adamhl@stanford.edu>

=head1 SEE ALSO

puppet(8)

=cut
