#!/usr/bin/ruby
#
# Copyright (c) 2017-2019 Catalyst.net Ltd
#
# This file is part of puppet-masterless.
#
# puppet-masterless 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 3 of the
# License, or (at your option) any later version.
#
# puppet-masterless 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 puppet-masterless. If not, see http://www.gnu.org/licenses/
#

require 'fileutils'
require 'securerandom'
require 'shellwords'

#|
#| Usage: puppet-masterless <command> [<argument> ...] [-- puppet arguments ...]
#|
#| Applies a local puppet project to a remote host.
#|
#| Available commands:
#|
#|   apply                     - apply a project to a remote host
#|   package                   - package a project as a shell script
#|   help                      - display manual page
#|
#| Available arguments:
#|
#|   confdir <pathname>        - main project directory
#|   manifest <filename>       - manifest to apply
#|   hiera_config <filename>   - passed to --hiera_config
#|   modulepath <pathname>     - passed to --modulepath
#|   to <hostname>             - hostname of remote machine
#|   puppet <executable>       - location of puppet executable on remote host
#|   sudo <command>            - location of sudo executable on remote host
#|   as <username>             - name of user running puppet-apply
#|   file <filename>           - add file or directory to package
#|   output file <filename>    - output filename for package
#|
#| At least "confdir" must be specified.
#|

module PuppetMasterless
  ARCHIVE_NAME = 'puppet-package'
  ARCHIVE_OPTIONS = '--ignore-failed-read --exclude-vcs'
  DEFAULT_PUPPET = 'puppet'
  DEFAULT_SUDO = 'sudo'
  REMOTE_USER = 'root'
  REMOTE_WORKDIR = '/tmp'
  VERSION = %{0.2.4}

  class Main
    def initialize
      @files = []
      @args = []
      @path = "#{ARCHIVE_NAME}-#{SecureRandom.hex(4)}"
      @output = "#{ARCHIVE_NAME}-#{SecureRandom.hex(4)}"
      @puppet = DEFAULT_PUPPET
      @sudo = DEFAULT_SUDO
      @user = REMOTE_USER
      @workdir = REMOTE_WORKDIR
    end

    def usage(n = 1)
      STDERR.puts(banner)
      exit(n)
    end

    def version(n = 0)
      STDERR.puts("puppet-masterless #{VERSION}")
      exit(n)
    end

    def collect(args)
      while word = args.shift
        case word
        when 'with', 'and'
        when 'confdir' then @confdir = collect_file(args)
        when 'manifest' then @manifest = collect_file(args)
        when 'modulepath' then @modulepath = collect_file(args)
        when 'hiera_config' then @hiera_config = collect_file(args)
        when 'puppet' then @puppet = args.shift
        when 'sudo' then @sudo = args.shift
        when 'to' then @hostname = args.shift
        when 'as' then @user = args.shift
        when 'file' then @files << collect_file(args)
        when '--', nil then @args = args.slice!(0..-1)
        when 'output' then collect_output(args)
        else fail 'Invalid argument: ' << word
        end
      end
    end

    def validate
      fail 'No confdir specified' unless @confdir
      fail 'No puppet specified' unless @puppet
      fail 'No output file specified' unless @output

      data = File.join(@confdir, 'data')
      modules = File.join(@confdir, 'modules')
      manifests = File.join(@confdir, 'manifests')
      hiera_yaml = File.join(@confdir, 'hiera.yaml')
      puppet_conf = File.join(@confdir, 'puppet.conf')

      @manifest ||= manifests
      @modulepath ||= modules
      @hiera_config ||= hiera_yaml

      fail 'No such confdir: ' << @confdir unless File.directory?(@confdir)
      fail 'No such manifest: ' << @manifest unless File.exist?(@manifest)

      @files << data if File.directory?(data)
      @files << puppet_conf if File.file?(puppet_conf)

      @modulepath = nil unless File.directory?(@modulepath)
      @hiera_config = nil unless File.file?(@hiera_config)
    end

    def package
      create_distribution
    end

    def apply
      @hostname ? apply_remote : apply_local
    end

    def help
      display_manual
    end

  private

    def banner
      File.read(__FILE__).lines.grep(/^#\|/).map { |s| s.gsub(/^#\| ?/, '') }
    end

    def display_manual
      manual = File.read(__FILE__).lines.grep(/^#1/).map { |s| s.gsub(/^#1 ?/, '') }
      raise 'No manual entry' if manual.empty?
      i, o = IO.pipe
      o.puts(manual)
      STDIN.reopen(i)
      exec('man', '-l', '/dev/stdin')
    end

    def collect_output(args)
      usage(1) unless args.shift == 'file'
      @output = args.shift
    end

    def collect_file(args)
      path = File.expand_path(file = args.shift)

      unless path.start_with?(Dir.pwd << '/')
        fail 'All files must be within the current directory: ' << file
      end

      path.sub(/#{Dir.pwd}\/*/, '')
    end

    def archive_command
      command = "tar -cf- #{ARCHIVE_OPTIONS} --transform 's|^|#{@path.gsub("'", "'\''")}/|'"
      command << ' ' << @files.shelljoin
      command << ' ' << @manifest.shellescape
      command << ' ' << @modulepath.shellescape if @modulepath
      command << ' ' << @hiera_config.shellescape if @hiera_config
      command << ' | gzip -f'
    end

    def local_apply_command
      command = "#{@puppet} apply #{@manifest.shellescape} --verbose --show_diff --color true"
      command << ' --confdir ' << @confdir.shellescape
      command << ' --modulepath ' << @modulepath.shellescape if @modulepath
      command << ' --hiera_config ' << @hiera_config.shellescape if @hiera_config
      command << ' ' << @args.shelljoin
    end

    def remote_apply_command
      command = "trap 'rm -f #{@workdir.shellescape}/#{@output.shellescape}' EXIT"
      command << "; #{@sudo} -u #{@user.shellescape} #{@workdir.shellescape}/#{@output.shellescape}"
    end

    def apply_local
      STDERR.puts('Notice: Applying locally')
      fail 'Apply command failed' unless system(local_apply_command)
    end

    def apply_remote
      STDERR.puts("Notice: Creating distribution")
      create_distribution

      begin
        STDERR.puts('Notice: Copying to ' << @hostname)
        fail 'Copy command failed' unless system("scp -q #{@output.shellescape} #{@hostname.shellescape}:#{@workdir.shellescape}")
      ensure
        FileUtils.rm_f(@output)
      end

      STDERR.puts('Notice: Applying to ' << @hostname)
      fail 'Apply command failed' unless system("ssh -q -t #{@hostname.shellescape} #{remote_apply_command.shellescape}")
    end

    def create_distribution
      File.open(@output, 'w') do |o|
        o.puts('#!/bin/sh -e')
        o.puts('umask 077')
        o.puts('cd "$(dirname "$0")"')
        o.puts('trap "rm -rf ' << @workdir.shellescape << '/' << @path.shellescape << '" EXIT')
        o.puts('sed -n 10,\\$p "$0" | gunzip -f | tar -xf-')
        o.puts('cd ' << @path.shellescape)
        o.puts(local_apply_command)
        o.puts('exit 0')
        o.puts('# EOF')
        o.chmod(0755)
      end

      unless system(archive_command << ' >> ' << @output.shellescape)
        fail 'Archive command failed: ' << archive_command
      end
    end
  end
end

main = PuppetMasterless::Main.new

begin
  case ARGV.shift
  when 'package'
    main.collect(ARGV)
    main.validate
    main.package
  when 'apply'
    main.collect(ARGV)
    main.validate
    main.apply
  when 'help'
    main.help
  when 'version', '--version'
    main.version(0)
  when '-h', '--help', nil
    main.usage(0)
  else
    main.usage(1)
  end
rescue => e
  STDERR.puts("Error: #{File.basename($0)}: #{e.message}")
  exit(1)
end

#1 .Dd October 10, 2019
#1 .Dt PUPPET-MASTERLESS 1
#1 .Os
#1 .Sh NAME
#1 .Nm puppet-masterless
#1 .Nd apply a local puppet project to a remote host
#1 .Sh SYNOPSIS
#1 .Nm
#1 .Ar command
#1 .Ar [ argument ... ]
#1 .No [ --
#1 .Ar puppet-apply argument ...
#1 .No ]
#1 .Sh DESCRIPTION
#1 The
#1 .Nm
#1 program packages a Puppet project as a self-contained shell script and
#1 optionally executes it on a remote machine via SSH.
#1 .Pp
#1 Some simple conventions are used to decide what files to include in the
#1 package. For a list of files that will be automatically included, refer
#1 to
#1 .Sx FILES .
#1 .Sh COMMANDS
#1 .Bl -tag -width Ds
#1 .It Cm package
#1 Package a project as a self-contained shell script.
#1 .It Cm apply
#1 Apply a project to a remote host, or to the local machine if no hostname
#1 is specified with the
#1 .Cm to
#1 argument.
#1 .El
#1 .Sh ARGUMENTS
#1 .Bl -tag -width Ds
#1 .It Cm confdir Aq pathname
#1 Set the root directory of the Puppet project. This value is passed to
#1 .Fl -confdir
#1 when running
#1 .Xr puppet-apply 8 .
#1 .Pp
#1 This argument is required.
#1 .It Cm manifest Aq filename
#1 Specify the main Puppet manifest to apply. This may be a file or
#1 directory.
#1 .Pp
#1 Defaults to
#1 .Dq Ao confdir Ac Ns /manifests
#1 (if that file is present).
#1 .It Cm hiera_config Aq filename
#1 Specify the Hiera configuration file to use. This value is passed to
#1 .Fl -hiera_config
#1 when running
#1 .Xr puppet-apply 8 .
#1 .Pp
#1 Defaults to
#1 .Dq Ao confdir Ac Ns /hiera.yaml
#1 (if that file is present).
#1 .It Cm modulepath Aq pathname
#1 Set the Puppet modules directory. This value is passed to
#1 .Fl -modulepath
#1 when running
#1 .Xr puppet-apply 8 .
#1 .Pp
#1 Defaults to
#1 .Dq Ao confdir Ac Ns /modules
#1 (if that file is present).
#1 .It Cm file Aq filename
#1 Add an arbitrary file or directory to the generated package. This can be
#1 used to include files in non-standard locations.
#1 .Pp
#1 Directories are added recursively, and this option can be repeated to
#1 include multiple files.
#1 .It Cm puppet Aq executable
#1 .Pp
#1 Specify the location of the
#1 .Xr puppet 8
#1 executable on the remote host. This is useful when the puppet binary is
#1 installed in a directory that is not on the active user's
#1 .Ev PATH
#1 (for example under
#1 .Pa /opt/puppetlabs/bin Ns ).
#1 .Pp
#1 Defaults to
#1 .Dq puppet .
#1 .It Cm output file Aq filename
#1 Set the output filename. If not given, a randomly-generated filename of the form
#1 .Dq puppet-package-XXXXXXXX
#1 is used.
#1 .Pp
#1 Applies only to the
#1 .Cm package
#1 command.
#1 .It Cm sudo Aq executable
#1 Specify the location of the
#1 .Xr sudo 1
#1 executable on the remote host. This can also be used to specify an
#1 alternative command for privilege elevation, for example
#1 .Xr doas 1 .
#1 .Pp
#1 Applies only to the
#1 .Cm apply
#1 command.
#1 .Pp
#1 Defaults to
#1 .Dq sudo .
#1 .It Cm as Aq username
#1 Set the name of the user that will run
#1 .Xr puppet-apply 8
#1 on the remote host.
#1 .Xr sudo 1
#1 is used to become this user before execution.
#1 .Pp
#1 Applies only to the
#1 .Cm apply
#1 command.
#1 .Pp
#1 Defaults to
#1 .Dq root .
#1 .It Cm to Aq hostname
#1 Specify the remote host to provision. If not given, the project will be
#1 applied directly to the local machine (without packaging or SSH).
#1 .Pp
#1 Applies only to the
#1 .Cm apply
#1 command.
#1 .It Cm and, with
#1 These words are ignored. They can be used to make an invocation of the
#1 program read sensibly (or insensibly, as desired).
#1 .It Cm -- [ Ar argument ... ]
#1 Two dashes indicate the end of program arguments. Any remaining
#1 arguments are passed to
#1 .Xr puppet-apply 8
#1 verbatim.
#1 .\" empty par for -T html spacing
#1 .Pp
#1 \&
#1 .El
#1 For more information about the
#1 .Cm confdir ,
#1 .Cm hiera_config ,
#1 .Cm manifest , No and
#1 .Cm modulepath No settings, refer to the following Puppet documentation:
#1 .Pp
#1 .Bl -dash -compact
#1 .It
#1 .Lk https://puppet.com/docs/puppet/5.5/dirs_confdir.html confdir
#1 .It
#1 .Lk https://puppet.com/docs/puppet/5.5/hiera_config_yaml_5.html hiera_config
#1 .It
#1 .Lk https://puppet.com/docs/puppet/5.5/dirs_manifest.html manifest
#1 .It
#1 .Lk https://puppet.com/docs/puppet/5.5/dirs_modulepath.html modulepath
#1 .El
#1 .Sh FILES
#1 The following files are automatically included in the package, when
#1 present:
#1 .\" empty par for -T html spacing
#1 .Pp
#1 \&
#1 .Bl -column "                  "
#1 .It Ao confdir Ac Ns /puppet.conf Ta - Default Puppet configuration file.
#1 .It Ao confdir Ac Ns /hiera.yaml  Ta - Default hiera configuration file.
#1 .It Ao confdir Ac Ns /data        Ta - Default hiera data directory.
#1 .It Ao confdir Ac Ns /manifests   Ta - Default manifest path.
#1 .It Ao confdir Ac Ns /modules     Ta - Default module path.
#1 .El
#1 .Pp
#1 Directories are included recursively.
#1 .Sh EXIT STATUS
#1 .Ex -std
#1 .Sh EXAMPLES
#1 Apply the Puppet project in
#1 .Pa ./puppet
#1 directly to the local machine:
#1 .Bd -literal -offset indent
#1 puppet-masterless apply confdir puppet
#1 .Ed
#1 .Pp
#1 Apply the
#1 .Pa site.pp
#1 manifest from the project in
#1 .Pa ./config
#1 to host 10.0.8.1 as user
#1 .Dq charlize :
#1 .Bd -literal -offset indent
#1 puppet-masterless apply confdir config \\
#1   with manifest config/manifests/site.pp \\
#1   to 10.0.8.1 as charlize
#1 .Ed
#1 .Pp
#1 Bundle the
#1 .Pa test
#1 project as a shell script, including a non-standard Hiera configuration
#1 file location and data directory:
#1 .Bd -literal -offset indent
#1 puppet-masterless package confdir test \\
#1   with file hiera and hiera_config hiera/test.yaml \\
#1   output file puppet.sh
#1 .Ed
#1 .Pp
#1 .Sh CAVEATS
#1 For this program to be useful, Puppet must be installed on the remote
#1 host.
#1 .Pp
#1 All file paths must be relative to the current working directory. An
#1 error is signaled whenever an absolute path is specified.
#1 .Pp
#1 This program is only intended to work with Puppet 4.10 and 5.5. It may
#1 work with other versions of Puppet, or it may not.
#1 .Pp
#1 The SSH functionality of this program is intentionally simplistic. If
#1 you need more complex behaviour when connecting to remote machines, use
#1 the
#1 .Cm package
#1 command in combination with a tool like
#1 .Xr ansible 1 ,
#1 .Xr cap 1 or
#1 .Xr fab 1 .
#1 .Pp
#1 Very little information is displayed when an error occurs.
#1 .Sh SECURITY CONSIDERATIONS
#1 The
#1 .Cm apply
#1 command uses
#1 .Xr sudo 1
#1 to assume a given user's identity on the remote host, with all of the
#1 security issues that implies.
#1 .Pp
#1 When applying manifests to a remote machine, any secrets included in the
#1 project will be present on the filesystem for as long as it takes to run
#1 .Xr puppet-apply 8 .
#1 .Sh IMPLEMENTATION NOTES
#1 .Nm
#1 is a single Ruby script with no Gem dependencies. It should be possible
#1 to copy and run on any machine with Ruby 1.9 or newer.
#1 .Sh SEE ALSO
#1 .Xr puppet 8 ,
#1 .Xr puppet-apply 8 ,
#1 .Xr puppet-master 8
#1 .Sh AUTHORS
#1 .An Evan Hanson Aq Mt evanh@catalyst.net.nz
