From 4bfb1397a90a9bd68621aa74cd86ff98e9a8f4ef Mon Sep 17 00:00:00 2001 From: Paul Geraghty Date: Fri, 27 Sep 2019 00:27:10 +0200 Subject: [PATCH] Add documentation, improve specs, refactor Output module Output module needed to be simplified structurally. Also added docs badge & example to README. --- README.md | 7 +- ansible-wrapper.gemspec | 2 + lib/ansible-wrapper.rb | 4 ++ lib/ansible/ad_hoc.rb | 39 +++++++++-- lib/ansible/config.rb | 27 ++++++-- lib/ansible/output.rb | 118 ++++++++++++++++++++++---------- lib/ansible/playbook.rb | 37 +++++++--- lib/ansible/safe_pty.rb | 4 ++ lib/ansible/shortcuts.rb | 6 ++ spec/ansible/ansible_spec.rb | 6 +- spec/ansible/output_spec.rb | 24 +++++-- spec/ansible/playbook_spec.rb | 10 +++ spec/fixtures/fail_playbook.yml | 11 +++ 13 files changed, 224 insertions(+), 71 deletions(-) create mode 100644 spec/fixtures/fail_playbook.yml diff --git a/README.md b/README.md index 1794d0f..4fdcb0c 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,14 @@ [![Build Status](https://travis-ci.com/pgeraghty/ansible-wrapper-ruby.svg?branch=master)](https://travis-ci.com/pgeraghty/ansible-wrapper-ruby) [![Coverage Status](https://coveralls.io/repos/github/pgeraghty/ansible-wrapper-ruby/badge.svg?branch=master)](https://coveralls.io/github/pgeraghty/ansible-wrapper-ruby?branch=master) [![Code Climate](https://codeclimate.com/github/pgeraghty/ansible-wrapper-ruby/badges/gpa.svg)](https://codeclimate.com/github/pgeraghty/ansible-wrapper-ruby) +[![Documentation](http://inch-ci.org/github/pgeraghty/ansible-wrapper-ruby.svg?branch=master)](http://inch-ci.org/github/pgeraghty/ansible-wrapper-ruby) #### A lightweight Ruby wrapper around Ansible that allows for ad-hoc commands and playbook execution. The primary purpose is to support easy streaming output. ## Requirements Ensure [Ansible](http://docs.ansible.com/intro_getting_started.html) is installed and available to shell commands i.e. in PATH. -[Tested](https://travis-ci.org/pgeraghty/ansible-wrapper-ruby) with Ansible versions 1.9.4 and 2.0.0.2, but please create an issue if you use a version that fails. +[Tested](https://travis-ci.org/pgeraghty/ansible-wrapper-ruby) with Ansible versions 2.0.2 thru 2.8.5 and Ruby 2.1+, but please create an issue if you use a version that fails. ## Installation @@ -72,9 +73,9 @@ A['all -i localhost, --list-hosts'] # alias for Ansible::AdHoc.run A << '-i localhost, spec/fixtures/mock_playbook.yml' # alias for Ansible::Playbook.stream ``` -## Coming Soon +## Examples -* Streaming output example using Sinatra +* For a streaming output example using Sinatra, see the [examples/streaming](examples/streaming) folder. ## Development diff --git a/ansible-wrapper.gemspec b/ansible-wrapper.gemspec index 7e3f73d..d2fd531 100644 --- a/ansible-wrapper.gemspec +++ b/ansible-wrapper.gemspec @@ -3,6 +3,8 @@ lib = File.expand_path('../lib', __FILE__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'ansible/version' +# TODO set required ruby version?? + Gem::Specification.new do |spec| spec.name = 'ansible-wrapper' spec.version = Ansible::VERSION diff --git a/lib/ansible-wrapper.rb b/lib/ansible-wrapper.rb index 05e791f..f1acdce 100644 --- a/lib/ansible-wrapper.rb +++ b/lib/ansible-wrapper.rb @@ -3,6 +3,8 @@ require 'ansible/playbook' require 'ansible/output' +# A lightweight Ruby wrapper around Ansible that allows for ad-hoc commands and playbook execution. +# The primary purpose is to support easy streaming output. module Ansible include Ansible::Config include Ansible::Methods @@ -10,6 +12,8 @@ module Ansible extend self + # Enables shortcuts + # @see ansible/shortcuts.rb def enable_shortcuts! require 'ansible/shortcuts' end diff --git a/lib/ansible/ad_hoc.rb b/lib/ansible/ad_hoc.rb index bd1caa6..9e440fb 100644 --- a/lib/ansible/ad_hoc.rb +++ b/lib/ansible/ad_hoc.rb @@ -2,21 +2,41 @@ require 'json' module Ansible + # Ansible Ad-Hoc methods module Methods + # executable that runs Ansible Ad-Hoc commands BIN = 'ansible' - def one_off cmd + # Run an Ad-Hoc Ansible command + # @param cmd [String] the Ansible command to execute + # @return [String] the output + # @example Run a simple shell command with an inline inventory that only contains localhost + # one_off 'all -c local -a "echo hello"' + def one_off(cmd) + # TODO if debug then puts w/ colour `#{config.to_s "#{BIN} #{cmd}"}` end alias :[] :one_off - def list_hosts cmd + # Ask Ansible to list hosts + # @param cmd [String] the Ansible command to execute + # @return [String] the output + # @example List hosts with an inline inventory that only contains localhost + # list_hosts 'all -i localhost,' + def list_hosts(cmd) output = one_off("#{cmd} --list-hosts").gsub!(/\s+hosts.*:\n/, '').strip output.split("\n").map(&:strip) end - def parse_host_vars(host, inv_file, filter = 'hostvars[inventory_hostname]') - cmd = "all -m debug -a 'var=#{filter}' -i #{inv_file} -l #{host}" + # Fetches host variables via Ansible's debug module + # @param host [String] the ++ for target host(s) + # @param inv [String] the inventory host path or comma-separated host list + # @param filter [String] the variable filter + # @return [Hash] the variables pertaining to the host + # @example List variables for localhost + # parse_host_vars 'localhost', 'localhost,' + def parse_host_vars(host, inv, filter = 'hostvars[inventory_hostname]') + cmd = "all -m debug -a 'var=#{filter}' -i #{inv} -l #{host}" json = self[cmd].split(/>>|=>/).last # remove any colour added to console output @@ -31,11 +51,18 @@ def parse_host_vars(host, inv_file, filter = 'hostvars[inventory_hostname]') end end + # Provides static access to Ad-Hoc methods module AdHoc - include Ansible::Config - include Ansible::Methods + include Config + include Methods extend self + + # Run an Ad-Hoc Ansible command + # @see Methods#one_off + # @param cmd [String] the Ansible command to execute + # @return [String] the output + # @since 0.2.1 alias :run :one_off end end \ No newline at end of file diff --git a/lib/ansible/config.rb b/lib/ansible/config.rb index ba72e52..9975828 100644 --- a/lib/ansible/config.rb +++ b/lib/ansible/config.rb @@ -1,31 +1,41 @@ module Ansible + # Ansible configuration module Config PATH = 'lib/ansible/' # IP_OR_HOSTNAME = /((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3})$|^((([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9]))\n/ SKIP_HOSTVARS = %w(ansible_version inventory_dir inventory_file inventory_hostname inventory_hostname_short group_names groups omit playbook_dir) VERSION = `ansible --version`.split("\n").first.split.last rescue nil # nil when Ansible not installed + # Default configuration options DefaultConfig = Struct.new(:env, :extra_vars, :params) do + # @!attribute env + # @return [Hash] environment variables + # @!attribute params + # @return [Hash] parameters + # @!attribute extra_vars + # @return [Hash] extra variables to pass to Ansible + def initialize self.env = { 'ANSIBLE_FORCE_COLOR' => 'True', 'ANSIBLE_HOST_KEY_CHECKING' => 'False' } - self.params = { - debug: false - } - - # options self.extra_vars = { # skip creation of .retry files 'retry_files_enabled' => 'False' } # TODO support --ssh-common-args, --ssh-extra-args # e.g. ansible-playbook --ssh-common-args="-o ServerAliveInterval=60" -i inventory install.yml + + self.params = { + debug: false + } end + # Pass additional options to Ansible # NB: --extra-vars can also accept JSON string, see http://stackoverflow.com/questions/25617273/pass-array-in-extra-vars-ansible + # @return [String] command-line options def options x = extra_vars.each_with_object('--extra-vars=\'') { |kv, a| a << "#{kv.first}=\"#{kv.last}\" " }.strip+'\'' if extra_vars unless extra_vars.empty? # can test with configure { |config| config.extra_vars.clear } @@ -33,6 +43,9 @@ def options [x, '--ssh-extra-args=\'-o UserKnownHostsFile=/dev/null\'']*' ' end + # Output configuration as a string for the command-line + # @param cmd [String] command to be appended to the command-line produced + # @return [Config, DefaultConfig] the configuration def to_s(cmd) entire_cmd = [env.each_with_object([]) { |kv, a| a << kv*'=' } * ' ', cmd, options]*' ' puts entire_cmd if params[:debug] @@ -40,6 +53,8 @@ def to_s(cmd) end end + # Create and yield configuration + # @return [Config, DefaultConfig] the configuration def configure @config ||= DefaultConfig.new yield(@config) if block_given? @@ -48,6 +63,8 @@ def configure block_given? ? self : @config end + # accessor for config + # @return [DefaultConfig] the configuration def config @config || configure end diff --git a/lib/ansible/output.rb b/lib/ansible/output.rb index 85866de..ff413ea 100644 --- a/lib/ansible/output.rb +++ b/lib/ansible/output.rb @@ -2,49 +2,91 @@ require 'erb' module Ansible + # Output module provides formatting of Ansible output module Output - COLOR = { - '1' => 'font-weight: bold', - '30' => 'color: black', - '31' => 'color: red', - '32' => 'color: green', - '33' => 'color: yellow', - '34' => 'color: blue', - '35' => 'color: magenta', - '36' => 'color: cyan', - '37' => 'color: white', - '90' => 'color: grey' - } - - def self.to_html(line, stream='') - s = StringScanner.new(ERB::Util.h line) - while(!s.eos?) - if s.scan(/\e\[([0-1])?[;]?(3[0-7]|90|1)m/) - bold, colour = s[1], s[2] - styles = [] - - styles << COLOR[bold] if bold.to_i == 1 - styles << COLOR[colour] - - span = - # in case of invalid colours, although this may be impossible - if styles.compact.empty? - %{} - else - %{} - end - - stream << span - elsif s.scan(/\e\[0m/) - stream << %{} - elsif s.scan(/\e\[[^0]*m/) - stream << '' + # Generate HTML for an output string formatted with ANSI escape sequences representing colours and styling + # @param ansi [String] an output string formatted with escape sequences to represent formatting + # @param stream [String] a stream or string (that supports +<<+) to which generated HTML will be appended + # @return the stream provided or a new String + # @example List hosts with an inline inventory that only contains localhost + # to_html "\e[90mGrey\e[0m" => 'Grey' + def self.to_html(ansi, stream='') + Ansi2Html.new(ansi).to_html stream + end + + # Converter for strings containing with ANSI escape sequences + class Ansi2Html + # Hash of colors to convert shell colours to CSS + COLOR = { + '1' => 'font-weight: bold', + '30' => 'color: black', + '31' => 'color: red', + '32' => 'color: green', + '33' => 'color: yellow', + '34' => 'color: blue', + '35' => 'color: magenta', + '36' => 'color: cyan', + '37' => 'color: white', + '90' => 'color: grey' + } + + SUPPORTED_STYLE_PATTERN = /\e\[([0-1])?[;]?(3[0-7]|90|1)m/ + END_ESCAPE_SEQUENCE_PATTERN = /\e\[0m/ + UNSUPPORTED_STYLE_PATTERN = /\e\[[^0]*m/ + IGNORED_OUTPUT = /./m + + OPEN_SPAN_TAG = %{} + CLOSE_SPAN_TAG = %{} + + # Create StringScanner for string + # @param line [String] a stream or string (that supports +<<+) to which generated HTML will be appended + def initialize(line) + # ensure any HTML tag characters are escaped + @strscan = StringScanner.new(ERB::Util.h line) + end + + # Generate HTML from string formatted with ANSI escape sequences + # @return [String, IO] the HTML + def to_html(stream) + until @strscan.eos? + stream << generate_html + end + + stream + end + + + private + + # Scan string and generate HTML + def generate_html + if @strscan.scan SUPPORTED_STYLE_PATTERN + open_tag + elsif @strscan.scan END_ESCAPE_SEQUENCE_PATTERN + CLOSE_SPAN_TAG + elsif @strscan.scan UNSUPPORTED_STYLE_PATTERN + OPEN_SPAN_TAG else - stream << s.scan(/./m) + @strscan.scan IGNORED_OUTPUT end end - stream + # Generate opening HTML tag, which may contain a style attribute + # @return [String] opening tag + def open_tag + bold, colour = @strscan[1], @strscan[2] + styles = [] + + styles << COLOR[bold] if bold.to_i == 1 + styles << COLOR[colour] + + # in case of invalid colours, although this may be impossible + if styles.compact.empty? + OPEN_SPAN_TAG + else + %{} + end + end end end end \ No newline at end of file diff --git a/lib/ansible/playbook.rb b/lib/ansible/playbook.rb index dfb72a7..8079e05 100644 --- a/lib/ansible/playbook.rb +++ b/lib/ansible/playbook.rb @@ -2,38 +2,53 @@ require 'ansible/safe_pty' module Ansible + # Ansible Playbook methods module PlaybookMethods + # executable that runs Ansible Playbooks BIN = 'ansible-playbook' - def playbook pb + # Run playbook, returning output + # @param pb [String] path to playbook + # @return [String] output + def playbook(pb) + # TODO if debug then puts w/ colour `#{config.to_s "#{BIN} #{pb}"}` end alias :<< :playbook - def stream pb - # Use PTY because otherwise output is buffered - SafePty.spawn config.to_s("#{BIN} #{pb}") do |r,w,p| # add -vvvv here for verbose + # Stream execution of a playbook using PTY because otherwise output is buffered + # @param pb [String] path to playbook + # @return [Integer] exit status + def stream(pb) + cmd = config.to_s("#{BIN} #{pb}") + + SafePty.spawn cmd do |r,_,_| # add -vvvv here for verbose until r.eof? do line = r.gets block_given? ? yield(line) : puts(line) - raise "FAILED: #{line}" if line.include?('fatal: [') - raise Playbook::Exception.new("ERROR: #{line}") if line.include?('ERROR!') - # TODO raise if contains FAILED! + case line + when /fatal: \[/ then raise Playbook::Exception.new("FAILED: #{line}") + when /ERROR!/,/FAILED!/ then raise Playbook::Exception.new("ERROR: #{line}") + end end end end end -end -module Ansible + # Provides static access to Playbook methods module Playbook - include Ansible::Config - include Ansible::PlaybookMethods + include Config + include PlaybookMethods extend self + + # Run playbook, returning output + # @param pb [String] path to playbook + # @return [String] output alias :run :playbook + # Exception to represent Playbook failures class Exception < RuntimeError; end end end \ No newline at end of file diff --git a/lib/ansible/safe_pty.rb b/lib/ansible/safe_pty.rb index f980ed7..4aeb944 100644 --- a/lib/ansible/safe_pty.rb +++ b/lib/ansible/safe_pty.rb @@ -1,6 +1,10 @@ require 'pty' +# Wrapper for PTY pseudo-terminal module Ansible::SafePty + # Spawns process for command + # @param command [String] command + # @return [Integer] exit status def self.spawn(command) PTY.spawn(command) do |r,w,p| diff --git a/lib/ansible/shortcuts.rb b/lib/ansible/shortcuts.rb index 7add0d3..24492b8 100644 --- a/lib/ansible/shortcuts.rb +++ b/lib/ansible/shortcuts.rb @@ -1,10 +1,16 @@ module Ansible extend self + # shortcut for executing an Ad-Hoc command + # @param cmd [String] the command-line to pass + # @see AdHoc#run def [](cmd) AdHoc.run cmd end + # shortcut to run a Playbook, streaming the output + # @param cmd [String] the command-line to pass + # @see Playbook#stream def <<(cmd) Playbook.stream cmd end diff --git a/spec/ansible/ansible_spec.rb b/spec/ansible/ansible_spec.rb index 1a52fad..8e704cb 100644 --- a/spec/ansible/ansible_spec.rb +++ b/spec/ansible/ansible_spec.rb @@ -12,7 +12,11 @@ Ansible.configure { |config| config.env['SOME_ENV_VAR'] = 'False' } }.to change { Ansible.config.env['SOME_ENV_VAR'] }.from(nil).to('False') - expect(Ansible.config.to_s '').to include('SOME_ENV_VAR=False') + expect(Ansible.config.to_s '').to include 'SOME_ENV_VAR=False' + end + + pending 'check Config params debug output' do + fail end before { suppress_output } diff --git a/spec/ansible/output_spec.rb b/spec/ansible/output_spec.rb index 54582b6..6715d70 100644 --- a/spec/ansible/output_spec.rb +++ b/spec/ansible/output_spec.rb @@ -21,27 +21,27 @@ module Output it 'closes a span tag when the appropriate sequence is detected' do output = "\e[32mGreen\e[0m" - expect(Output.to_html output).to match /Green<\/span>/ + expect(Output.to_html output).to eq %{Green} end it 'ignores unstyled text after an escape sequence' do output = "\e[90mGrey\e[0mDefault" - expect(Output.to_html output).to match /Grey<\/span>Default/ + expect(Output.to_html output).to eq %{GreyDefault} end it 'ignores newlines' do output = "\e[32mGreen\e[0m\n" - expect(Output.to_html output).to match /Green<\/span>\n/ + expect(Output.to_html output).to eq %{Green\n} end it 'ignores tags left open' do output = "\e[0m\n\e[0;32mGreen\e[0m\n\e[0;34mBlue" - expect(Output.to_html output).to match /<\/span>\nGreen<\/span>\nBlue/ + expect(Output.to_html output).to eq %{\nGreen\nBlue} end it 'handles bold output alongside colour with dual styles in a single tag' do output = "\e[1;35mBold Magenta\e[0m" - expect(Output.to_html output).to eq "Bold Magenta" + expect(Output.to_html output).to eq %{Bold Magenta} end it 'scrubs unsupported escape sequences' do @@ -70,19 +70,29 @@ module Output it 'correctly formats output of a streamed playbook' do output = '' - Ansible.stream(['-i', 'localhost,', 'spec/fixtures/mock_playbook.yml']*' ') do |line| + Ansible.stream('-i localhost, spec/fixtures/mock_playbook.yml') do |line| output << Ansible::Output.to_html(line) end expect(output).to match /ok/ end + it 'includes original stream content alongside formatted output of a streamed playbook' do + output = '

Some tag

' + Ansible.stream('-i localhost, spec/fixtures/mock_playbook.yml') do |line| + Ansible::Output.to_html(line, output) + end + + expect(output).to match /^

Some tag<\/h1>/ + expect(output).to match /ok/ + end + context 'for a non-existent playbook' do output = '' it 'raises an error' do expect { - Ansible.stream(['-i', 'localhost,', 'does_not_exist.yml']*' ') do |line| + Ansible.stream('-i localhost, does_not_exist.yml') do |line| output << Ansible::Output.to_html(line) end }.to raise_error(Ansible::Playbook::Exception, /ERROR! the playbook: does_not_exist.yml could not be found/) diff --git a/spec/ansible/playbook_spec.rb b/spec/ansible/playbook_spec.rb index 2ebc286..c818ea7 100644 --- a/spec/ansible/playbook_spec.rb +++ b/spec/ansible/playbook_spec.rb @@ -1,3 +1,5 @@ +require 'spec_helper' + module Ansible describe Playbook do before(:all) do @@ -28,6 +30,14 @@ module Ansible expect { Playbook.stream('-i localhost, file_not_found.yml --list-hosts') { |l| next } }.to raise_exception(Playbook::Exception, /could not be found/) end + it 'raises an error upon a fatal outcome (unreachable node)' do + expect { Playbook.stream('-i localhost.does_not_exist, spec/fixtures/fail_playbook.yml') { |l| next } }.to raise_exception(Playbook::Exception, (/FAILED/ && /fatal/ && /UNREACHABLE/)) + end + + pending 'test ignore_failures is skipped' do + fail + end + it 'defaults to standard output when streaming an Ansible Playbook if no block is given' do expect { Playbook.stream '-i localhost, spec/fixtures/mock_playbook.yml' }.to output(/Test task/).to_stdout end diff --git a/spec/fixtures/fail_playbook.yml b/spec/fixtures/fail_playbook.yml new file mode 100644 index 0000000..bc62a79 --- /dev/null +++ b/spec/fixtures/fail_playbook.yml @@ -0,0 +1,11 @@ +--- + +- name: Testing Playbook + hosts: all + gather_facts: no + + tasks: + - name: Test task + register: test_msg + changed_when: "'Test' in test_msg.stderr" + shell: echo 'Test' \ No newline at end of file