diff --git a/exe/pupent b/exe/pupent new file mode 100755 index 0000000000..22c2a82943 --- /dev/null +++ b/exe/pupent @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'pupent/cli' +require 'bolt/logger' +require 'bolt/error' + +begin + cli = PupEnt::CLI.new(ARGV) + exitcode = cli.execute + exit exitcode +rescue PupEnt::CLIExit + exit +rescue Bolt::Error => e + exit e.error_code +end diff --git a/lib/pupent/access.rb b/lib/pupent/access.rb new file mode 100644 index 0000000000..765071f196 --- /dev/null +++ b/lib/pupent/access.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'io/console' + +module PupEnt + class Access + RBAC_PREFIX = ":4433/rbac-api" + + def initialize(token_location) + @token_location = token_location + @logger = Bolt::Logger.logger(self) + end + + def login(client, lifetime) + $stderr.print("Enter your Puppet Enterprise credentials.\n") + $stderr.print("Username: ") + username = $stdin.gets.to_s.chomp + $stderr.print("Password: ") + # noecho ensures we don't print anything to the console + # while the user is typing the password + password = $stdin.noecho(&:gets).to_s.chomp! + $stderr.puts + + body = { + login: username, + password: password, + lifetime: lifetime + } + response, = client.pe_post("/v1/auth/token", body, sensitive: true) + FileUtils.mkdir_p(File.dirname(token_location)) + File.open(token_location, File::CREAT | File::WRONLY) do |fd| + fd.write(response['token']) + end + end + + def show + File.open(token_location, File::RDONLY).read + rescue Errno::ENOENT + msg = "No token file!" + @logger.error(msg) + raise Bolt::Error.new(msg, 'bolt/no-token') + end + + def delete_token + @logger.info("Deleting token...") + File.delete(token_location) + @logger.info("Done.") + rescue Errno::ENOENT + msg = "No token file!" + @logger.error(msg) + raise Bolt::Error.new(msg, 'bolt/no-token') + end + + def token_location + @token_location ||= File.join(ENV['HOME'], '.pupent', 'token') + end + end +end diff --git a/lib/pupent/cli.rb b/lib/pupent/cli.rb new file mode 100644 index 0000000000..2b3cc20e96 --- /dev/null +++ b/lib/pupent/cli.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require_relative '../pupent/pupent_option_parser' +require_relative '../pupent/config' +require_relative '../pupent/http_client' +require_relative '../pupent/access' +require_relative '../pupent/code' + +module PupEnt + class CLI + def initialize(argv) + @command, @action, @object, cli_options = PupEntOptionParser.parse(argv) + # This merge is done in a specific order on purpose: + # + # The final parsed options are read from the config options on disk first, then + # we merge anything sent over the CLI. This forces the precedence that CLI options + # override anything set on disk. + @parsed_options = Config.read_config(cli_options[:config_file]).merge(cli_options) + Bolt::Logger.initialize_logging + Bolt::Logger.logger(:root).add_appenders Logging.appenders.stderr( + 'console', + layout: Bolt::Logger.console_layout(true), + level: @parsed_options[:log_level] + ) + @pe_host_url = parse_pe_host_url(@command, @parsed_options[:pe_host], @parsed_options[:service_url]) + @ca_cert = @parsed_options[:ca_cert] + if @parsed_options[:save_config] + require_relative '../pupent/config' + Config.save_config(@parsed_options) + end + end + + def parse_pe_host_url(command, pe_host, service_url) + if pe_host + case command + when 'access' + "https://" + pe_host + Access::RBAC_PREFIX + when 'code' + "https://" + pe_host + Code::CODE_MANAGER_PREFIX + end + elsif service_url + service_url + end + end + + # Only create a client when we need to + def new_client + HttpClient.new(@pe_host_url, @ca_cert) + end + + def execute + case @command + when 'access' + case @action + when 'login' + Access.new( + @parsed_options[:token_file] + ).login(new_client, @parsed_options[:lifetime], @parsed_options) + 0 + when 'show' + $stdout.puts Access.new(@parsed_options[:token_file]).show + 0 + when 'delete-token-file' + Access.new(@parsed_options[:token_file]).delete_token + 0 + end + when 'code' + case @action + when 'deploy' + $stdout.puts Code.new( + @parsed_options[:token_file], + new_client + ).deploy(@object, @parsed_options[:wait], @parsed_options[:all]) + 0 + when 'status' + $stdout.puts Code.new( + @parsed_options[:token_file], + new_client + ).status + 0 + when 'deploy-status' + $stdout.puts Code.new( + @parsed_options[:token_file], + new_client + ).deploy_status(@object) + 0 + end + end + end + end +end diff --git a/lib/pupent/code.rb b/lib/pupent/code.rb new file mode 100644 index 0000000000..f8d09ae292 --- /dev/null +++ b/lib/pupent/code.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'json' +require_relative "../pupent/access" + +module PupEnt + class Code + CODE_MANAGER_PREFIX = ":8170/code-manager" + + def initialize(token_location, http_client) + @token = Access.new(token_location).show + @client = http_client + end + + def deploy(environments, wait, all) + body = {} + if all + body["deploy-all"] = true + else + body[:environments] = environments.split(',') + end + + if wait + body[:wait] = true + end + response, = @client.pe_post('/v1/deploys', body, headers: { "X-Authentication": @token }) + JSON.pretty_generate(response) + end + + def status + response, = @client.pe_get('/v1/status', headers: { "X-Authentication": @token }) + JSON.pretty_generate(response) + end + + def deploy_status(deploy_id) + url_path = if deploy_id && !deploy_id.empty? + "/v1/deploys/status?id=#{deploy_id}" + else + "/v1/deploys/status" + end + response, = @client.pe_get(url_path, headers: { "X-Authentication": @token }) + JSON.pretty_generate(response) + end + end +end diff --git a/lib/pupent/config.rb b/lib/pupent/config.rb new file mode 100644 index 0000000000..b41d9876c8 --- /dev/null +++ b/lib/pupent/config.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'json' +require 'fileutils' + +module PupEnt + module Config + DEFAULT_PUPENT_LOCATION = File.join(ENV['HOME'], ".puppetlabs", "pupent") + DEFAULTS = { + token_file: File.join(DEFAULT_PUPENT_LOCATION, "token"), + log_level: "info", + # pupent access defaults + lifetime: "15m" + }.freeze + + def self.read_config(file_location) + FileUtils.mkdir_p(DEFAULT_PUPENT_LOCATION) + file_location ||= File.join(DEFAULT_PUPENT_LOCATION, "config.json") + parsed_data = nil + # Use a+ so it won't fail if the config file doesn't exist, just create an empty one + File.open(file_location, 'a+') do |fd| + config_data = fd.read + parsed_data = if config_data && !config_data.empty? + JSON.parse(config_data) + else + {} + end + end + parsed_data.transform_keys! { |key| key.to_s.downcase.gsub("-", "_").to_sym } + DEFAULTS.merge(parsed_data) + end + + def self.save_config(parsed_options) + file_location ||= File.join(DEFAULT_PUPENT_LOCATION, "config.json") + File.open(file_location, 'w') do |fd| + config_to_save = parsed_options.slice(:token_file, :ca_cert, :pe_host, :service_url) + fd.write(JSON.pretty_generate(config_to_save)) + end + end + end +end diff --git a/lib/pupent/http_client.rb b/lib/pupent/http_client.rb new file mode 100644 index 0000000000..62cda46caa --- /dev/null +++ b/lib/pupent/http_client.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require 'net/http' +require 'puppet/ssl' +require 'json' +require_relative '../bolt/error' + +module PupEnt + class HttpClient + MISSING_CA_ERROR = <<-ERR + +ERROR: Unable to read CA certificate + +pupent requires a valid CA certificate from the PE primary in order to communicate +using TLS over https. You can download the CA certificate with: + +bolt file download /etc/puppetlabs/puppet/ssl/certs/ca.pem [Local file location] --targets [PE Primary Hostname] + +See "bolt file download --help" for more information on using that command + ERR + + def initialize(pe_url, ca_cert) + @logger = Bolt::Logger.logger(self) + if pe_url.nil? || pe_url.empty? + msg = "ERROR: URL of PE Primary missing or empty" + @logger.error(msg) + raise Bolt::Error.new(msg, 'bolt/http-error') + end + if ca_cert.nil? || ca_cert.empty? + @logger.error(MISSING_CA_ERROR) + raise Bolt::Error.new(MISSING_CA_ERROR, 'bolt/http-error') + end + @logger.debug("Read and parse CA") + ca_certs = File.read(ca_cert).scan(/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m).map do |cert| + OpenSSL::X509::Certificate.new(cert) + end + + @logger.debug("Create SSL context") + ssl_prov = Puppet::SSL::SSLProvider.new + @ssl_context = ssl_prov.create_root_context( + cacerts: ca_certs, + revocation: false + ) + @client = Puppet.runtime[:http] + @pe_url = pe_url + rescue Errno::ENOENT + @logger.error(MISSING_CA_ERROR) + raise Bolt::Error.new(MISSING_CA_ERROR, 'bolt/http-error') + end + + def pe_get(url_path, headers: {}, sensitive: false) + handle_request(url_path, sensitive: sensitive) do |full_url| + @client.get( + full_url, + headers: { 'Content-Type': 'application/json' }.merge(headers), + options: { ssl_context: @ssl_context } + ) + end + end + + def pe_post(url_path, body, headers: {}, sensitive: false) + handle_request(url_path, body: body, sensitive: sensitive) do |full_url, json_body| + @client.post( + full_url, + json_body, + headers: { 'Content-Type': 'application/json' }.merge(headers), + options: { ssl_context: @ssl_context } + ) + end + end + + def pe_put(url_path, body, headers: {}, sensitive: false) + handle_request(url_path, body: body, sensitive: sensitive) do |full_url, json_body| + @client.put( + full_url, + json_body, + headers: { 'Content-Type': 'application/json' }.merge(headers), + options: { ssl_context: @ssl_context } + ) + end + end + + private + + # Handle URL creation, JSON encoding/decoding, and errors for http requests + def handle_request(url_path, body: nil, sensitive: false) + # Make sure the URL starts with the PE URL, otherwise assume the + # path was provided without the hostname + full_url = if url_path.include?(@pe_url) + URI(url_path) + else + URI(@pe_url + url_path) + end + # Don't attempt to JSON.generate a nil body + json_body = if body.nil? + nil + else + JSON.generate(body) + end + + http_response = if body.nil? + yield full_url + else + yield full_url, json_body + end + + # Throw and catch the response error. Throwing allows us to + # capture if another part of the stack throws this error. + # rubocop:disable Style/RaiseArgs + unless http_response.success? + raise Puppet::HTTP::ResponseError.new(http_response) + end + # rubocop:enable Style/RaiseArgs + + if http_response.body.nil? || http_response.body.empty? + ["", http_response.code] + else + [JSON.parse(http_response.body), http_response.code] + end + rescue Puppet::HTTP::ResponseError => e + msg = "" + if sensitive + msg = "PE API request returned HTTP code #{e.response.code}" + @logger.error(msg) + # Use trace to print the message in case the request contained something sensitive + @logger.trace("Failure message: #{e.message}") + else + msg = "PE API request returned HTTP code #{e.response.code} with message\n#{e.message}" + @logger.error(msg) + end + raise Bolt::Error.new(msg, 'bolt/http-error') + rescue StandardError => e + msg = "" + if sensitive + msg = "Exception #{e.class.name} thrown while attempting API request to PE" + @logger.error(msg) + # Use trace to print the message in case the request contained something sensitive + @logger.trace("Exception message: #{e.message}") + else + msg = "Exception #{e.class.name} thrown while attempting API request to PE with message\n#{e.message}" + @logger.error(msg) + end + raise Bolt::Error.new(msg, 'bolt/unexpected-error') + end + end +end diff --git a/lib/pupent/pupent_option_parser.rb b/lib/pupent/pupent_option_parser.rb new file mode 100644 index 0000000000..2e85ad6674 --- /dev/null +++ b/lib/pupent/pupent_option_parser.rb @@ -0,0 +1,284 @@ +# frozen_string_literal: true + +require 'optparse' +require_relative '../bolt/version' + +module PupEnt + class CLIExit < StandardError; end + + module PupEntOptionParser + COLORS = { + dim: "2", # Dim, the other color of the rainbow + red: "31", + green: "32", + yellow: "33", + cyan: "36" + }.freeze + + def self.colorize(color, string) + if $stdout.isatty + "\033[#{COLORS[color]}m#{string}\033[0m" + else + string + end + end + + ALL_HELP = <<~HELP + #{colorize(:cyan, 'Name')} + pupent + + #{colorize(:cyan, 'Usage')} + pupent [action] [options] + + #{colorize(:cyan, 'Description')} + pupent is a cli helper included with Bolt that provides access on the command line + to various functions of Puppet Enterprise + + #{colorize(:cyan, 'Subcommands')} + access PE token management + code Remote puppet code management + + #{colorize(:cyan, 'Options')} + HELP + + CODE_HELP = <<~HELP + #{colorize(:cyan, 'Name')} + code + + #{colorize(:cyan, 'Usage')} + pupent code [action] [options] + + #{colorize(:cyan, 'Actions')} + deploy Runs remote code deployments + help Help about any command + print-config Prints out the resolved pupent configuration + status Checks Code Manager status + + #{colorize(:cyan, 'Description')} + Runs remote code deployments with the Code Manager service. + + #{colorize(:cyan, 'Options')} + HELP + + CODE_DEPLOY_HELP = <<~HELP + #{colorize(:cyan, 'Name')} + code deploy + + #{colorize(:cyan, 'Usage')} + pupent code deploy [ | --all] [options] + + #{colorize(:cyan, 'Description')} + Run remote code deployment(s) using code manager. + + #{colorize(:cyan, 'Options')} + HELP + + CODE_STATUS_HELP = <<~HELP + #{colorize(:cyan, 'Name')} + code status + + #{colorize(:cyan, 'Usage')} + pupent code status [options] + + #{colorize(:cyan, 'Description')} + Print the status of the code manager service + + #{colorize(:cyan, 'Options')} + HELP + + CODE_DEPLOY_STATUS_HELP = <<~HELP + #{colorize(:cyan, 'Name')} + code deploy-status + + #{colorize(:cyan, 'Usage')} + pupent code deploy-status [deploy ID] [options] + + #{colorize(:cyan, 'Description')} + Print the status of code deployments + + #{colorize(:cyan, 'Options')} + HELP + + ACCESS_HELP = <<~HELP + #{colorize(:cyan, 'Name')} + access + + #{colorize(:cyan, 'Usage')} + pupent access [action] [options] + + #{colorize(:cyan, 'Actions')} + login Login and generate a token + show Print the locally saved token + delete-token-file Delete the locally saved token + + #{colorize(:cyan, 'Description')} + pupent access provides commands for fetching a new token from Puppet Enterprise + + #{colorize(:cyan, 'Options')} + HELP + + ACCESS_LOGIN_HELP = <<~HELP + #{colorize(:cyan, 'Name')} + access login + + #{colorize(:cyan, 'Usage')} + pupent access login [options] + + #{colorize(:cyan, 'Description')} + login to Puppet Enterprise and generate an RBAC token usable for further authentication + + #{colorize(:cyan, 'Options')} + HELP + + ACCESS_SHOW_HELP = <<~HELP + #{colorize(:cyan, 'Name')} + access show + + #{colorize(:cyan, 'Usage')} + pupent access show [options] + + #{colorize(:cyan, 'Description')} + Print the locally saved Puppet Enterprise RBAC token to stdout + + #{colorize(:cyan, 'Options')} + HELP + + ACCESS_DELETE_HELP = <<~HELP + #{colorize(:cyan, 'Name')} + access delete-token-file + + #{colorize(:cyan, 'Usage')} + pupent access delete-token-file [options] + + #{colorize(:cyan, 'Description')} + Delete the locally saved Puppet Enterprise RBAC token + + #{colorize(:cyan, 'Options')} + HELP + + # rubocop:disable Layout/LineLength + def self.all_globals(parser) + parser.on( + "--log-level LEVEL", + "Logging level to display" + ) + parser.on( + "--pe-host FQDN", + "Fully Qualified Domain Name of the PE primary" + ) + parser.on( + "--ca-cert CERT", + "Location on the local system of the PE Primary's CA Certificate. (default \"/etc/puppetlabs/puppet/ssl/certs/ca.pem\")" + ) + parser.on( + "-c", + "--config-file FILE", + "Location on the local system of the config file for PupEnt" + ) + parser.on( + "-s", + "--save-config", + "Save token-file, ca-cert, and pe-host/service-url configuration passed as CLI args to the local config file" + ) + parser.on_tail("-h", "--help", "Prints help and usage information") + parser.on_tail("-V", "--version", "Show version") + end + + def self.access_globals(parser) + parser.on("--service-url FULL_URL", "FQDN, port, and API prefix of server where token issuing service/server can be contacted") + end + + def self.access_login(parser) + parser.on("--lifetime LIFETIME", "Lifetime of the token") + parser.on("-t", "--token-file FILE", "Location on the local system of the token file. (default #{ENV['HOME']}/.pupent/token") + end + + def self.code_globals(parser) + parser.on("--service-url FULL_URL", "FQDN, port, and API prefix of server where code manager can be contacted") + end + + def self.code_deploy(parser) + parser.on("--all", "Run deployments for all environments") + parser.on("--wait", "Wait for the server to finish deploying") + end + # rubocop:enable Layout/LineLength + + def self.parse(argv) + # Do not set any default values here: merging the values + # provided on the CLI with default values happens in the + # CLI class, and default values are defined in the Config + # class. + args = {} + parser = OptionParser.new do |prsr| + all_globals(prsr) + prsr.banner = ALL_HELP + end + + # Use a second OptionParser to parse all possible + # options, allowing us to find the command and + # action + remaining = OptionParser.new do |prsr| + all_globals(prsr) + access_globals(prsr) + access_login(prsr) + code_globals(prsr) + code_deploy(prsr) + end.permute(argv) + command = remaining.shift + action = remaining.shift + object = remaining.shift + + case command + when "access" + parser.banner = ACCESS_HELP + access_globals(parser) + case action + when "login" + parser.banner = ACCESS_LOGIN_HELP + access_login(parser) + when "show" + parser.banner = ACCESS_SHOW_HELP + # No options to add + when "delete-token-file" + parser.banner = ACCESS_DELETE_HELP + # No options to add + else + args[:help] = true + end + when 'code' + parser.banner = CODE_HELP + code_globals(parser) + case action + when 'deploy' + parser.banner = CODE_DEPLOY_HELP + code_deploy(parser) + when 'status' + parser.banner = CODE_STATUS_HELP + # No options to add + when 'deploy-status' + parser.banner = CODE_DEPLOY_STATUS_HELP + # No options to add + else + args[:help] = true + end + else + args[:help] = true + end + + parser.parse(argv, into: args) + # All keys should become downcased symbols using underscores everywhere. + args.transform_keys! { |key| key.to_s.downcase.gsub("-", "_").to_sym } + # If the user asked for help or version, just bail here. + if args[:version] + $stdout.puts Bolt::VERSION + raise PupEnt::CLIExit + # Check for :help second, since --version probably doesn't include a + # command and :help might also be true + elsif args[:help] + $stderr.puts(parser.help) + raise PupEnt::CLIExit + end + [command, action, object, args] + end + end +end