From ec4d4ae03ba75ac8aaf4b94ea8bf3890b215a220 Mon Sep 17 00:00:00 2001 From: Paul Geraghty Date: Tue, 24 Sep 2019 18:51:47 +0200 Subject: [PATCH] Tweak new functionality, augment tests --- .github/workflows/test_via_docker.yml | 6 +- README.md | 8 +-- ansible-wrapper.gemspec | 2 + bin/console | 2 +- examples/streaming/run.rb | 2 +- lib/ansible-wrapper.rb | 1 - lib/ansible/ad_hoc.rb | 46 +++++++------ lib/ansible/config.rb | 92 ++++++++++++------------- lib/ansible/output.rb | 67 +++++++++--------- lib/ansible/playbook.rb | 1 + spec/ansible/ad_hoc_spec.rb | 16 +++++ spec/ansible/ansible_spec.rb | 4 +- spec/ansible/output_spec.rb | 97 +++++++++++++++++++++++++++ spec/ansible/playbook_spec.rb | 14 ++-- spec/{ => fixtures}/mock_playbook.yml | 0 15 files changed, 240 insertions(+), 118 deletions(-) create mode 100644 spec/ansible/output_spec.rb rename spec/{ => fixtures}/mock_playbook.yml (100%) diff --git a/.github/workflows/test_via_docker.yml b/.github/workflows/test_via_docker.yml index 5be1685..94ba266 100644 --- a/.github/workflows/test_via_docker.yml +++ b/.github/workflows/test_via_docker.yml @@ -23,10 +23,10 @@ jobs: env: RUBY_VERSION: 2.6.4 ANSIBLE_VERSION: ${{ matrix.ansible }} - COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} run: > - docker run -e COVERALLS_REPO_TOKEN --rm -v $PWD:/app pgeraghty/ansible-ruby:$RUBY_VERSION-$ANSIBLE_VERSION - /bin/sh -c "cd /app && bundle install --jobs=3 --retry=3 && COVERALLS_RUN_LOCALLY=true bundle exec rake" + docker run --rm -v $PWD:/app pgeraghty/ansible-ruby:$RUBY_VERSION-$ANSIBLE_VERSION + /bin/sh -c "cd /app && bundle install --jobs=3 --retry=3 && + COVERALLS_REPO_TOKEN=${{ secrets.COVERALLS_REPO_TOKEN }} COVERALLS_RUN_LOCALLY=true bundle exec rake" - name: Test against Ruby 2.5.6 env: diff --git a/README.md b/README.md index 0ed1b3a..1794d0f 100644 --- a/README.md +++ b/README.md @@ -43,15 +43,15 @@ Ansible::AdHoc.run 'all -m shell -a "echo Test" -i localhost,' ### Playbooks ```ruby -Ansible::Playbook.run '-i localhost, spec/mock_playbook.yml' +Ansible::Playbook.run '-i localhost, spec/fixtures/mock_playbook.yml' ``` ```ruby -Ansible::Playbook.stream('-i localhost, spec/mock_playbook.yml') # defaults to standard output +Ansible::Playbook.stream('-i localhost, spec/fixtures/mock_playbook.yml') # defaults to standard output ``` ```ruby -Ansible::Playbook.stream('-i localhost, spec/mock_playbook.yml') { |line_of_output| puts line_of_output } +Ansible::Playbook.stream('-i localhost, spec/fixtures/mock_playbook.yml') { |line_of_output| puts line_of_output } ``` ### Shortcuts @@ -69,7 +69,7 @@ A['all -i localhost, --list-hosts'] # alias for Ansible::AdHoc.run ``` ```ruby -A << '-i localhost, spec/mock_playbook.yml' # alias for Ansible::Playbook.stream +A << '-i localhost, spec/fixtures/mock_playbook.yml' # alias for Ansible::Playbook.stream ``` ## Coming Soon diff --git a/ansible-wrapper.gemspec b/ansible-wrapper.gemspec index 6314dba..7e3f73d 100644 --- a/ansible-wrapper.gemspec +++ b/ansible-wrapper.gemspec @@ -27,6 +27,8 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ['lib'] + spec.add_dependency 'json' + spec.add_development_dependency 'bundler','~> 1.10' spec.add_development_dependency 'rake', '~> 10.0' spec.add_development_dependency 'rspec' diff --git a/bin/console b/bin/console index 01b9c74..9523bc9 100755 --- a/bin/console +++ b/bin/console @@ -1,7 +1,7 @@ #!/usr/bin/env ruby require 'bundler/setup' -require 'ansible' +require 'ansible-wrapper' # You can add fixtures and/or initialization code here to make experimenting # with your gem easier. You can also use a different console, if you like. diff --git a/examples/streaming/run.rb b/examples/streaming/run.rb index 670ccc0..2b778c8 100644 --- a/examples/streaming/run.rb +++ b/examples/streaming/run.rb @@ -63,7 +63,7 @@ #content_type 'text/plain' stream do |out| out << CONSOLE_OUTPUT_START - Ansible.stream ['-i', 'localhost,', File.expand_path('../../../spec/mock_playbook.yml', __FILE__)]*' ' do |line| + Ansible.stream ['-i', 'localhost,', File.expand_path('../../../spec/fixtures/mock_playbook.yml', __FILE__)]*' ' do |line| Ansible::Output.to_html line, out end out << CONSOLE_OUTPUT_END diff --git a/lib/ansible-wrapper.rb b/lib/ansible-wrapper.rb index c2cdd04..05e791f 100644 --- a/lib/ansible-wrapper.rb +++ b/lib/ansible-wrapper.rb @@ -1,5 +1,4 @@ require 'ansible/version' -require 'ansible/config' require 'ansible/ad_hoc' require 'ansible/playbook' require 'ansible/output' diff --git a/lib/ansible/ad_hoc.rb b/lib/ansible/ad_hoc.rb index 9728d5e..bd1caa6 100644 --- a/lib/ansible/ad_hoc.rb +++ b/lib/ansible/ad_hoc.rb @@ -1,30 +1,36 @@ -module Ansible::Methods - BIN = 'ansible' +require 'ansible/config' +require 'json' - def one_off cmd - `#{config.to_s "#{BIN} #{cmd}"}` - end - alias :[] :one_off +module Ansible + module Methods + BIN = 'ansible' - # this solution should work for Ansible pre-2.0 as well as 2.0+ - def list_hosts cmd - one_off("#{cmd} --list-hosts").scan(IP_OR_HOSTNAME).map { |ip| ip.first.strip } - end + def one_off cmd + `#{config.to_s "#{BIN} #{cmd}"}` + end + alias :[] :one_off - def parse_host_vars(host, inv_file, filter = 'hostvars[inventory_hostname]') - hostvars_filter = filter - cmd = "all -m debug -a 'var=#{hostvars_filter}' -i #{inv_file} -l #{host} --one-line" - hostvars = JSON.parse(self[cmd].split(/>>|=>/).last) + def list_hosts cmd + output = one_off("#{cmd} --list-hosts").gsub!(/\s+hosts.*:\n/, '').strip + output.split("\n").map(&:strip) + end - if Gem::Version.new(Ansible::Config::VERSION) >= Gem::Version.new('2.0') - hostvars[hostvars_filter] - else - hostvars['var'][hostvars_filter] + def parse_host_vars(host, inv_file, filter = 'hostvars[inventory_hostname]') + cmd = "all -m debug -a 'var=#{filter}' -i #{inv_file} -l #{host}" + json = self[cmd].split(/>>|=>/).last + + # remove any colour added to console output + # TODO move to Output module as #bleach, perhaps use term-ansicolor + # possibly replace regexp with /\e\[(?:(?:[349]|10)[0-7]|[0-9]|[34]8;5;\d{1,3})?m/ + # possibly use ANSIBLE_NOCOLOR? or --nocolor + json = json.strip.chomp.gsub(/\e\[[0-1][;]?(3[0-7]|90|1)?m/, '') + + hostvars = JSON.parse(json) + + hostvars[filter] end end -end -module Ansible module AdHoc include Ansible::Config include Ansible::Methods diff --git a/lib/ansible/config.rb b/lib/ansible/config.rb index e5b15a9..ba72e52 100644 --- a/lib/ansible/config.rb +++ b/lib/ansible/config.rb @@ -1,53 +1,55 @@ -module Ansible::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]))/ - 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 - - DefaultConfig = Struct.new(:env, :extra_vars, :params) do - 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 +module Ansible + 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 + + DefaultConfig = Struct.new(:env, :extra_vars, :params) do + 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 + end + + # NB: --extra-vars can also accept JSON string, see http://stackoverflow.com/questions/25617273/pass-array-in-extra-vars-ansible + 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 } + + [x, '--ssh-extra-args=\'-o UserKnownHostsFile=/dev/null\'']*' ' + end + + def to_s(cmd) + entire_cmd = [env.each_with_object([]) { |kv, a| a << kv*'=' } * ' ', cmd, options]*' ' + puts entire_cmd if params[:debug] + entire_cmd + end end - # NB: --extra-vars can also accept JSON string, see http://stackoverflow.com/questions/25617273/pass-array-in-extra-vars-ansible - 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 } + def configure + @config ||= DefaultConfig.new + yield(@config) if block_given? - [x, '--ssh-extra-args=\'-o UserKnownHostsFile=/dev/null\'']*' ' + # allow chaining if block given + block_given? ? self : @config end - def to_s(cmd) - entire_cmd = [env.each_with_object([]) { |kv, a| a << kv*'=' } * ' ', cmd, options]*' ' - puts entire_cmd if params[:debug] - entire_cmd + def config + @config || configure end end - - def configure - @config ||= DefaultConfig.new - yield(@config) if block_given? - - # allow chaining if block given - block_given? ? self : @config - end - - def config - @config || configure - end end \ No newline at end of file diff --git a/lib/ansible/output.rb b/lib/ansible/output.rb index 6686c2a..0b92daf 100644 --- a/lib/ansible/output.rb +++ b/lib/ansible/output.rb @@ -1,47 +1,50 @@ require 'strscan' require 'erb' -module Ansible::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' - } +module Ansible + 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.captures - styles = [] + 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.captures + styles = [] - styles << COLOR[bold] if bold.to_i == 1 - styles << COLOR[colour] + styles << COLOR[bold] if bold.to_i == 1 + styles << COLOR[colour] - span = - if styles.empty? - %{} - else - %{} - end + span = + # in case of invalid colours, although this may be impossible + if styles.compact.empty? + %{} + else + %{} + end - stream << span - else - if s.scan(/\e\[0m/) + stream << span + elsif s.scan(/\e\[0m/) stream << %{} + elsif s.scan(/\e\[[^0]*m/) + stream << '' else stream << s.scan(/./m) end end - end - stream + stream + end end end \ No newline at end of file diff --git a/lib/ansible/playbook.rb b/lib/ansible/playbook.rb index dde3455..dfb72a7 100644 --- a/lib/ansible/playbook.rb +++ b/lib/ansible/playbook.rb @@ -1,3 +1,4 @@ +require 'ansible/config' require 'ansible/safe_pty' module Ansible diff --git a/spec/ansible/ad_hoc_spec.rb b/spec/ansible/ad_hoc_spec.rb index 2e9c97c..ef8ebde 100644 --- a/spec/ansible/ad_hoc_spec.rb +++ b/spec/ansible/ad_hoc_spec.rb @@ -11,5 +11,21 @@ module Ansible expect(AdHoc.run 'all -i localhost, --list-hosts').to match /localhost/ end end + + describe '.list_hosts' do + it 'can list hosts for an inline inventory' do + inline_inv = %w(localhost 99.99.99.99) + cmd = "all -i #{inline_inv*','}, --list-hosts" + + expect(AdHoc.list_hosts cmd).to match inline_inv + end + end + + describe '.parse_host_vars' do + it 'can parse and return default host vars' do + host_vars = AdHoc.parse_host_vars 'all', 'localhost,' + expect(host_vars['inventory_hostname']).to match 'localhost' + end + end end end diff --git a/spec/ansible/ansible_spec.rb b/spec/ansible/ansible_spec.rb index 8dabb4b..1a52fad 100644 --- a/spec/ansible/ansible_spec.rb +++ b/spec/ansible/ansible_spec.rb @@ -20,7 +20,7 @@ Ansible.enable_shortcuts! disable_host_key_checking - cmd = '-i localhost, spec/mock_playbook.yml' + cmd = '-i localhost, spec/fixtures/mock_playbook.yml' expect(A << cmd).to be_a Integer expect(A['all -i localhost, --list-hosts']).to match /localhost/ end @@ -33,7 +33,7 @@ module Nodes extend self def install!(ip) - stream ['-i', ip, 'spec/mock_playbook.yml']*' ' + stream ['-i', ip, 'spec/fixtures/mock_playbook.yml']*' ' end end diff --git a/spec/ansible/output_spec.rb b/spec/ansible/output_spec.rb new file mode 100644 index 0000000..66a36c7 --- /dev/null +++ b/spec/ansible/output_spec.rb @@ -0,0 +1,97 @@ +require 'spec_helper' + +module Ansible + module Output + describe '.to_html' do + context 'can convert ANSI escape sequence colours to HTML styles' do + it 'ignores text without escape sequences' do + output = "Plain\nText\nHere" + expect(Output.to_html output).to eq output + end + + it 'opens a span tag with style when the appropriate sequence is detected' do + output = "\e[31mRed" + expect(Output.to_html output).to match /Red/ + end + + it 'ignores unstyled text before an escape sequence' do + output = "Default \e[31mRed" + expect(Output.to_html output).to match /Default Red/ + end + + 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>/ + 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/ + end + + it 'ignores newlines' do + output = "\e[32mGreen\e[0m\n" + expect(Output.to_html output).to match /Green<\/span>\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/ + 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" + end + + it 'scrubs unsupported escape sequences' do + output = "\e[38;5;118mBright Green - unsupported\e[0m" + expect(Output.to_html output).to eq "Bright Green - unsupported" + end + + it 'handles some malformed cases (missing semicolon)' do + output = "\e[132mBright Green" + expect(Output.to_html output).to eq 'Bright Green' + end + + # This code may be entirely unreachable as regexp appears to be very specific + it 'handles situations where no style attribute should be added to the tag' do + output = "\e[0;99Nothing\e[0m" + + s = instance_double("StringScanner", captures: []) + allow(s).to receive(:eos?).and_return(false, true) + allow(s).to receive(:scan).and_return(true, '') + + class_double("StringScanner", new: s).as_stubbed_const + + expect(Output.to_html output).to match // + end + + it 'correctly formats output of a streamed playbook' do + output = '' + Ansible.stream(['-i', 'localhost,', 'spec/fixtures/mock_playbook.yml']*' ') do |line| + output << Ansible::Output.to_html(line) + end + + expect(output).to match /ok=1/ + 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| + output << Ansible::Output.to_html(line) + end + }.to raise_error(Ansible::Playbook::Exception, /ERROR! the playbook: does_not_exist.yml could not be found/) + end + + it 'outputs an error message' do + expect(output).to match /ERROR! the playbook: does_not_exist.yml could not be found<\/span>/ + end + end + end + end + end +end \ No newline at end of file diff --git a/spec/ansible/playbook_spec.rb b/spec/ansible/playbook_spec.rb index b33c007..2ebc286 100644 --- a/spec/ansible/playbook_spec.rb +++ b/spec/ansible/playbook_spec.rb @@ -6,22 +6,18 @@ module Ansible describe '.run' do it 'can execute a basic Ansible Playbook command on localhost' do - expect(Playbook.run '-i localhost, spec/mock_playbook.yml --list-hosts').to match /localhost/ + expect(Playbook.run '-i localhost, spec/fixtures/mock_playbook.yml --list-hosts').to match /localhost/ end before { suppress_output } it 'can execute a basic Ansible Playbook' do - expect(Playbook.run '-i localhost, spec/mock_playbook.yml').to match /TASK(.?) \[Test task\]/ - end - - it 'can execute a basic Ansible Playbook' do - expect(Playbook.run '-i localhost, spec/mock_playbook.yml').to match /TASK(.?) \[Test task\]/ + expect(Playbook.run '-i localhost, spec/fixtures/mock_playbook.yml').to match /TASK(.?) \[Test task\]/ end end describe '.stream' do it 'can stream the output from execution of an Ansible Playbook' do - cmd = '-i localhost, spec/mock_playbook.yml' + cmd = '-i localhost, spec/fixtures/mock_playbook.yml' expect { |b| Playbook.stream cmd, &b }.to yield_control expect(Playbook.stream(cmd) { |l| next }).to be_a Integer @@ -33,12 +29,12 @@ module Ansible end it 'defaults to standard output when streaming an Ansible Playbook if no block is given' do - expect { Playbook.stream '-i localhost, spec/mock_playbook.yml' }.to output(/Test task/).to_stdout + expect { Playbook.stream '-i localhost, spec/fixtures/mock_playbook.yml' }.to output(/Test task/).to_stdout end it 'returns a warning as part of the output when the inventory does not exist' do # TODO should probably raise an error for this behaviour (perhaps switch to pending) - expect { Playbook.stream '-i localhost spec/mock_playbook.yml' }.to output(/Unable to parse|Host file not found/).to_stdout + expect { Playbook.stream '-i localhost spec/fixtures/mock_playbook.yml' }.to output(/Unable to parse|Host file not found/).to_stdout end end end diff --git a/spec/mock_playbook.yml b/spec/fixtures/mock_playbook.yml similarity index 100% rename from spec/mock_playbook.yml rename to spec/fixtures/mock_playbook.yml