diff --git a/Gemfile b/Gemfile index d9d36f04..3f511cd2 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,10 @@ source 'https://rubygems.org' +gem 'chef', ENV['CHEF_VERSION'] || '~> 11.0.0' +gem 'yard', '~> 0.8.6' +gem 'yard-chef', '~> 1.0.0' + group :test do - gem 'chef', '~> 11.0.0' - gem 'chefspec', :github => 'acrmp/chefspec' - gem 'fauxhai', '~> 0.1.1' -end \ No newline at end of file + gem 'chefspec', '~> 1.0.0' + gem 'strainer', '~> 2.0.0' +end diff --git a/Gemfile.lock b/Gemfile.lock index 77112981..3bb33d0a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,18 +1,30 @@ -PATH - remote: ~/Development/chefspec - specs: - chefspec (1.0.0.rc1) - chef (>= 10.0) - erubis - fauxhai (~> 0.1) - minitest-chef-handler (~> 0.6.0) - moneta (< 0.7.0) - rspec (~> 2.12.0) - GEM remote: https://rubygems.org/ specs: + activesupport (3.2.13) + i18n (= 0.6.1) + multi_json (~> 1.0) + addressable (2.3.4) + berkshelf (1.4.0) + activesupport (>= 3.2.0) + addressable + celluloid (>= 0.13.0) + chozo (>= 0.6.1) + faraday (>= 0.8.5) + hashie (>= 2.0.2) + json (>= 1.5.0) + minitar + mixlib-config (~> 1.1) + mixlib-shellout (~> 1.1) + multi_json (~> 1.5) + retryable + ridley (~> 0.9.0) + solve (>= 0.4.2) + thor (~> 0.18.0) + yajl-ruby builder (3.2.0) + celluloid (0.13.0) + timers (>= 1.0.0) chef (11.0.0) erubis highline (>= 1.6.9) @@ -27,35 +39,51 @@ GEM ohai (>= 0.6.0) rest-client (>= 1.0.4, < 1.7.0) yajl-ruby (~> 1.1) + chefspec (1.0.0) + chef (>= 10.0) + erubis + fauxhai (~> 0.1) + minitest-chef-handler (>= 0.6.0) + rspec (~> 2.0) + chozo (0.6.1) + activesupport (>= 3.2.0) + hashie (>= 2.0.2) + multi_json (>= 1.3.0) ci_reporter (1.8.4) builder (>= 2.1.2) - diff-lcs (1.1.3) + diff-lcs (1.2.4) erubis (2.7.0) + faraday (0.8.7) + multipart-post (~> 1.1) fauxhai (0.1.1) chef httparty net-ssh - highline (1.6.15) - httparty (0.10.2) + hashie (2.0.4) + highline (1.6.18) + httparty (0.11.0) multi_json (~> 1.0) multi_xml (>= 0.5.2) + i18n (0.6.1) ipaddress (0.8.0) json (1.6.1) - mime-types (1.21) - minitest (4.6.2) - minitest-chef-handler (0.6.8) + mime-types (1.23) + minitar (0.5.4) + minitest (4.7.3) + minitest-chef-handler (1.0.1) chef ci_reporter - minitest + minitest (~> 4.7.3) mixlib-authentication (1.3.0) mixlib-log mixlib-cli (1.3.0) mixlib-config (1.1.2) - mixlib-log (1.4.1) + mixlib-log (1.6.0) mixlib-shellout (1.1.0) - moneta (0.6.0) - multi_json (1.6.1) + multi_json (1.7.2) multi_xml (0.5.3) + multipart-post (1.2.0) + net-http-persistent (2.8) net-ssh (2.2.2) net-ssh-gateway (1.1.0) net-ssh (>= 1.99.1) @@ -70,23 +98,54 @@ GEM mixlib-shellout systemu yajl-ruby + redcarpet (2.1.1) rest-client (1.6.7) mime-types (>= 1.16) - rspec (2.12.0) - rspec-core (~> 2.12.0) - rspec-expectations (~> 2.12.0) - rspec-mocks (~> 2.12.0) - rspec-core (2.12.2) - rspec-expectations (2.12.1) - diff-lcs (~> 1.1.3) - rspec-mocks (2.12.2) + retryable (1.3.2) + ridley (0.9.1) + activesupport (>= 3.2.0) + addressable + celluloid (~> 0.13.0) + chozo (>= 0.6.0) + erubis + faraday (>= 0.8.4) + json (>= 1.5.0) + mixlib-authentication (>= 1.3.0) + mixlib-config (>= 1.1.0) + mixlib-log (>= 1.3.0) + mixlib-shellout (>= 1.1.0) + multi_json (>= 1.0.4) + net-http-persistent (>= 2.8) + net-ssh + retryable + solve (>= 0.4.1) + rspec (2.13.0) + rspec-core (~> 2.13.0) + rspec-expectations (~> 2.13.0) + rspec-mocks (~> 2.13.0) + rspec-core (2.13.1) + rspec-expectations (2.13.0) + diff-lcs (>= 1.1.3, < 2.0) + rspec-mocks (2.13.1) + solve (0.4.2) + json + strainer (2.0.0) + berkshelf (~> 1.3) systemu (2.5.2) + thor (0.18.1) + timers (1.1.0) yajl-ruby (1.1.0) + yard (0.8.6.1) + yard-chef (1.0.0) + redcarpet (~> 2.1.1) + yard (~> 0.8) PLATFORMS ruby DEPENDENCIES chef (~> 11.0.0) - chefspec! - fauxhai (~> 0.1.1) + chefspec (~> 1.0.0) + strainer (~> 2.0.0) + yard (~> 0.8.6) + yard-chef (~> 1.0.0) diff --git a/Strainerfile b/Strainerfile new file mode 100644 index 00000000..1be9e963 --- /dev/null +++ b/Strainerfile @@ -0,0 +1,2 @@ +knife: bundle exec knife cookbook test $COOKBOOK +spec: bundle exec rspec --color --format documentation diff --git a/libraries/entry.rb b/libraries/entry.rb index efdd1061..a91828bf 100644 --- a/libraries/entry.rb +++ b/libraries/entry.rb @@ -20,31 +20,20 @@ require 'ipaddr' +# An object representation of a single line in a hostsfile. +# +# @author Seth Vargo class Entry - attr_accessor :ip_address, :hostname, :aliases, :comment, :priority - - def initialize(options = {}) - raise ArgumentError, ':ip_address and :hostname are both required options' if options[:ip_address].nil? || options[:hostname].nil? - - @ip_address = IPAddr.new(options[:ip_address]) - @hostname = options[:hostname] - @aliases = [options[:aliases]].flatten.compact - @comment = options[:comment] - @priority = options[:priority] || calculate_priority(options[:ip_address]) - end - - def priority=(new_priority) - @calculated_priority = false - @priority = new_priority - end - class << self # Creates a new Hostsfile::Entry object by parsing a text line. The # `line` attribute will be in the following format: # # 1.2.3.4 hostname [alias[, alias[, alias]]] [# comment [@priority]] # - # This returns a new Entry object... + # @param [String] line + # the line to parse + # @return [Entry] + # a new entry object def parse(line) entry_part, comment_part = line.split('#', 2).collect { |part| part.strip.empty? ? nil : part.strip } @@ -69,8 +58,50 @@ def parse(line) end end - # Write out the entry as it appears in the hostsfile - def to_s + # @return [String] + attr_accessor :ip_address, :hostname, :aliases, :comment, :priority + + # Creates a new entry from the given options. + # + # @param [Hash] options + # a list of options to create the entry with + # @option options [String] :ip_address + # the IP Address for this entry + # @option options [String] :hostname + # the hostname for this entry + # @option options [String, Array] :aliases + # a alias or array of aliases for this entry + # @option options[String] :comment + # an optional comment for this entry + # @option options [Fixnum] :priority + # the relative priority of this entry (compared to others) + # + # @raise [ArgumentError] + # if neither :ip_address nor :hostname are supplied + def initialize(options = {}) + raise ArgumentError, ':ip_address and :hostname are both required options' if options[:ip_address].nil? || options[:hostname].nil? + + @ip_address = IPAddr.new(options[:ip_address]) + @hostname = options[:hostname] + @aliases = [options[:aliases]].flatten.compact + @comment = options[:comment] + @priority = options[:priority] || calculated_priority + end + + # Set a the new priority for an entry. + # + # @param [Fixnum] new_priority + # the new priority to set + def priority=(new_priority) + @calculated_priority = false + @priority = new_priority + end + + # The line representation of this entry. + # + # @return [String] + # the string representation of this entry + def to_line hosts = [hostname, aliases].flatten.join(' ') comments = "# #{comment.to_s}".strip @@ -81,16 +112,35 @@ def to_s [ip_address, hosts, comments].compact.join("\t").strip end - private - # Attempt to calculate the relative priority of each entry - def calculate_priority(ip_address) - @calculated_priority = true - ip_address = IPAddr.new(ip_address) - - return 81 if ip_address == IPAddr.new('127.0.0.1') - return 80 if IPAddr.new('127.0.0.0/8').include?(ip_address) # local - return 60 if ip_address.ipv4? # ipv4 - return 20 if ip_address.ipv6? # ipv6 - return 00 + # The string representation of this Entry + # + # @return [String] + # the string representation of this entry + def to_s + "#" end + + # The object representation of this Entry + # + # @return [String] + # the object representation of this entry + def inspect + "#" + end + + private + + # Calculates the relative priority of this entry. + # + # @return [Fixnum] + # the relative priority of this item + def calculated_priority + @calculated_priority = true + + return 81 if ip_address == IPAddr.new('127.0.0.1') + return 80 if IPAddr.new('127.0.0.0/8').include?(ip_address) # local + return 60 if ip_address.ipv4? # ipv4 + return 20 if ip_address.ipv6? # ipv6 + return 00 + end end diff --git a/libraries/manipulator.rb b/libraries/manipulator.rb index 159168ac..5bbcfa5f 100644 --- a/libraries/manipulator.rb +++ b/libraries/manipulator.rb @@ -18,14 +18,27 @@ # limitations under the License. # +require 'entry' + +require 'chef/application' +require 'digest/sha2' + class Manipulator attr_reader :node + # Create a new Manipulator object (aka an /etc/hosts manipulator). If a + # hostsfile is not found, a Chef::Application.fatal is risen, causing + # the process to terminate on the node and the converge will fail. + # + # @param [Chef::node] node + # the current Chef node + # @return [Manipulator] + # a class designed to manipulate the node's /etc/hosts file def initialize(node) @node = node.to_hash # Fail if no hostsfile is found - Chef::Log.fatal "No hostsfile exists at '#{hostsfile_path}'!" unless ::File.exists?(hostsfile_path) + Chef::Application.fatal! "No hostsfile exists at '#{hostsfile_path}'!" unless ::File.exists?(hostsfile_path) contents = ::File.readlines(hostsfile_path) @entries = contents.collect do |line| @@ -33,31 +46,57 @@ def initialize(node) end.compact end + # Return a list of all IP Addresses for this hostsfile. + # + # @return [Array] + # the list of IP Addresses def ip_addresses @entries.collect do |entry| entry.ip_address end.compact || [] end + # Add a new record to the hostsfile. + # + # @param [Hash] options + # a list of options to create the entry with + # @option options [String] :ip_address + # the IP Address for this entry + # @option options [String] :hostname + # the hostname for this entry + # @option options [String, Array] :aliases + # a alias or array of aliases for this entry + # @option options[String] :comment + # an optional comment for this entry + # @option options [Fixnum] :priority + # the relative priority of this entry (compared to others) def add(options = {}) @entries << Entry.new( - :ip_address => options[:ip_address], - :hostname => options[:hostname], - :aliases => options[:aliases], - :comment => options[:comment], - :priority => options[:priority] + :ip_address => options[:ip_address], + :hostname => options[:hostname], + :aliases => options[:aliases], + :comment => options[:comment], + :priority => options[:priority] ) end + # Update an existing entry. This method will do nothing if the entry + # does not exist. + # + # @param (see #add) def update(options = {}) if entry = find_entry_by_ip_address(options[:ip_address]) - entry.hostname = options[:hostname] - entry.aliases = options[:aliases] - entry.comment = options[:comment] - entry.priority = options[:priority] + entry.hostname = options[:hostname] + entry.aliases = options[:aliases] + entry.comment = options[:comment] + entry.priority = options[:priority] end end + # Append content to an existing entry. This method will add a new entry + # if one does not already exist. + # + # @param (see #add) def append(options = {}) if entry = find_entry_by_ip_address(options[:ip_address]) entry.aliases = [ entry.aliases, options[:hostname], options[:aliases] ].flatten.compact.uniq @@ -67,18 +106,30 @@ def append(options = {}) end end + # Remove an entry by it's IP Address + # + # @param [String] ip_address + # the IP Address of the entry to remove def remove(ip_address) if entry = find_entry_by_ip_address(ip_address) @entries.delete(entry) end end + # Safe save the entry file. + # @see #save! + # + # @return [Boolean] + # true if the record was successfully saved, false otherwise def save save! - rescue + true + rescue Exception false end + # Save the new hostsfile to the target machine. This method will only write the + # hostsfile if the current version has changed. In other words, it is convergent. def save! entries = [] entries << "#" @@ -92,14 +143,26 @@ def save! entries << "# Last updated: #{::Time.now}" entries << "#" entries << "" - entries += unique_entries.sort_by{ |e| [-e.priority.to_i, e.hostname.to_s] } + entries += unique_entries.map(&:to_line) entries << "" - ::File.open(hostsfile_path, 'w') do |file| - file.write( entries.join("\n") ) + contents = entries.join("\n") + contents_sha = Digest::SHA512.hexdigest(contents) + + # Only write out the file if the contents have changed... + if contents_sha != current_sha + ::File.open(hostsfile_path, 'w') do |f| + f.write(contents) + end end end + # Find an entry by the given IP Address. + # + # @param [String] ip_address + # the IP Address of the entry to detect + # @return [Entry, nil] + # the corresponding entry object, or nil if it does not exist def find_entry_by_ip_address(ip_address) @entries.detect do |entry| !entry.ip_address.nil? && entry.ip_address == ip_address @@ -107,50 +170,67 @@ def find_entry_by_ip_address(ip_address) end private - # Returns the path to the hostsfile. - def hostsfile_path - @hostsfile_path ||= case node['platform_family'] - when 'windows' - "#{node['kernel']['os_info']['system_directory']}\\drivers\\etc\\hosts" - else - # debian, rhel, fedora, suse, gentoo, slackware, arch, mac_os_x, windows - '/etc/hosts' - end - end - # This is a crazy way of ensuring unique objects in an array using a Hash - def unique_entries - remove_existing_hostnames - Hash[*@entries.map{ |entry| [entry.ip_address, entry] }.flatten].values - end + # The path to the current hostsfile. + # + # @return [String] + # the full path to the hostsfile, depending on the operating system + def hostsfile_path + @hostsfile_path ||= case node['platform_family'] + when 'windows' + "#{node['kernel']['os_info']['system_directory']}\\drivers\\etc\\hosts" + else + # debian, rhel, fedora, suse, gentoo, slackware, arch, mac_os_x, windows + '/etc/hosts' + end + end - # This method ensures that hostnames/aliases and only used once. It - # doesn't make sense to allow multiple IPs to have the same hostname - # or aliases. This method removes all occurrences of the existing - # hostname/aliases from existing records. - # - # This method also intelligently removes any entries that should no - # longer exist. - def remove_existing_hostnames - new_entry = @entries.pop - changed_hostnames = [ new_entry.hostname, new_entry.aliases ].flatten.uniq - - @entries = @entries.collect do |entry| - entry.hostname = nil if changed_hostnames.include?(entry.hostname) - entry.aliases = entry.aliases - changed_hostnames - - if entry.hostname.nil? - if entry.aliases.empty? - nil + # The current sha of the system hostsfile. + # + # @return [String] + # the sha of the current hostsfile + def current_sha + @current_sha ||= Digest::SHA512.hexdigest(File.read(hostsfile_path)) + end + + # This is a crazy way of ensuring unique objects in an array using a Hash. + # + # @return [Array] + # the sorted list of entires that are unique + def unique_entries + remove_existing_hostnames + + entries = Hash[*@entries.map{ |entry| [entry.ip_address, entry] }.flatten].values + entries.sort_by { |e| [-e.priority.to_i, e.hostname.to_s] } + end + + # This method ensures that hostnames/aliases and only used once. It + # doesn't make sense to allow multiple IPs to have the same hostname + # or aliases. This method removes all occurrences of the existing + # hostname/aliases from existing records. + # + # This method also intelligently removes any entries that should no + # longer exist. + def remove_existing_hostnames + new_entry = @entries.pop + changed_hostnames = [ new_entry.hostname, new_entry.aliases ].flatten.uniq + + @entries = @entries.collect do |entry| + entry.hostname = nil if changed_hostnames.include?(entry.hostname) + entry.aliases = entry.aliases - changed_hostnames + + if entry.hostname.nil? + if entry.aliases.empty? + nil + else + entry.hostname = entry.aliases.shift + entry + end else - entry.hostname = entry.aliases.shift entry end - else - entry - end - end.compact + end.compact - @entries << new_entry - end + @entries << new_entry + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 642f4f40..1b6d4c9e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,10 +1,10 @@ require 'chefspec' require 'fauxhai' -# Require our libraries. They aren't actually loaded early enough to mock. -Dir['libraries/*'].each do |library| - require File.expand_path(library) -end +lib = File.expand_path('../../libraries', __FILE__) +$:.unshift(lib) unless $:.include?(lib) +require 'entry' +require 'manipulator' $cookbook_paths = [ File.expand_path('../../..', __FILE__), diff --git a/spec/unit/entry_spec.rb b/spec/unit/entry_spec.rb index 346dfd59..625e362d 100644 --- a/spec/unit/entry_spec.rb +++ b/spec/unit/entry_spec.rb @@ -1,6 +1,50 @@ require 'spec_helper' describe Entry do + describe '.parse' do + it 'returns nil for invalid lines' do + expect(Entry.parse(' ')).to be_nil + end + + context '' do + let(:entry) { double('entry') } + + before do + Entry.stub(:new).and_return(entry) + end + + it 'parses just an ip_address and hostname' do + Entry.should_receive(:new).with(:ip_address => '1.2.3.4', :hostname => 'www.example.com', :aliases => [], :comment => nil, :priority => nil) + Entry.parse('1.2.3.4 www.example.com') + end + + it 'parses aliases' do + Entry.should_receive(:new).with(:ip_address => '1.2.3.4', :hostname => 'www.example.com', :aliases => ['foo', 'bar'], :comment => nil, :priority => nil) + Entry.parse('1.2.3.4 www.example.com foo bar') + end + + it 'parses a comment' do + Entry.should_receive(:new).with(:ip_address => '1.2.3.4', :hostname => 'www.example.com', :aliases => [], :comment => 'This is a comment!', :priority => nil) + Entry.parse('1.2.3.4 www.example.com # This is a comment!') + end + + it 'parses aliases and comments' do + Entry.should_receive(:new).with(:ip_address => '1.2.3.4', :hostname => 'www.example.com', :aliases => ['foo', 'bar'], :comment => 'This is a comment!', :priority => nil) + Entry.parse('1.2.3.4 www.example.com foo bar # This is a comment!') + end + + it 'parses priorities with comments' do + Entry.should_receive(:new).with(:ip_address => '1.2.3.4', :hostname => 'www.example.com', :aliases => [], :comment => 'This is a comment!', :priority => '40') + Entry.parse('1.2.3.4 www.example.com # This is a comment! @40') + end + + it 'parses priorities' do + Entry.should_receive(:new).with(:ip_address => '1.2.3.4', :hostname => 'www.example.com', :aliases => [], :comment => nil, :priority => '40') + Entry.parse('1.2.3.4 www.example.com # @40') + end + end + end + describe '.initialize' do subject { Entry.new(:ip_address => '2.3.4.5', :hostname => 'www.example.com', :aliases => ['foo', 'bar'], :comment => 'This is a comment!', :priority => 100) } @@ -49,8 +93,8 @@ expect(subject.comment).to be_nil end - it 'calls calculate_priority for @priority' do - Entry.any_instance.should_receive(:calculate_priority).with('2.3.4.5') + it 'calls calculated_priority for @priority' do + Entry.any_instance.should_receive(:calculated_priority) Entry.new(:ip_address => '2.3.4.5', :hostname => 'www.example.com') end end @@ -65,66 +109,36 @@ end end - describe '.parse' do - it 'returns nil for invalid lines' do - expect(Entry.parse(' ')).to be_nil - end - - context '' do - let(:entry) { double('entry') } + describe '#priority=' do + subject { Entry.new(:ip_address => '2.3.4.5', :hostname => 'www.example.com') } - before do - Entry.stub(:new).and_return(entry) - end - - it 'parses just an ip_address and hostname' do - Entry.should_receive(:new).with(:ip_address => '1.2.3.4', :hostname => 'www.example.com', :aliases => [], :comment => nil, :priority => nil) - Entry.parse('1.2.3.4 www.example.com') - end - - it 'parses aliases' do - Entry.should_receive(:new).with(:ip_address => '1.2.3.4', :hostname => 'www.example.com', :aliases => ['foo', 'bar'], :comment => nil, :priority => nil) - Entry.parse('1.2.3.4 www.example.com foo bar') - end - - it 'parses a comment' do - Entry.should_receive(:new).with(:ip_address => '1.2.3.4', :hostname => 'www.example.com', :aliases => [], :comment => 'This is a comment!', :priority => nil) - Entry.parse('1.2.3.4 www.example.com # This is a comment!') - end - - it 'parses aliases and comments' do - Entry.should_receive(:new).with(:ip_address => '1.2.3.4', :hostname => 'www.example.com', :aliases => ['foo', 'bar'], :comment => 'This is a comment!', :priority => nil) - Entry.parse('1.2.3.4 www.example.com foo bar # This is a comment!') - end - - it 'parses priorities with comments' do - Entry.should_receive(:new).with(:ip_address => '1.2.3.4', :hostname => 'www.example.com', :aliases => [], :comment => 'This is a comment!', :priority => '40') - Entry.parse('1.2.3.4 www.example.com # This is a comment! @40') - end + it 'sets the new priority' do + subject.priority = 50 + expect(subject.priority).to eq(50) + end - it 'parses priorities' do - Entry.should_receive(:new).with(:ip_address => '1.2.3.4', :hostname => 'www.example.com', :aliases => [], :comment => nil, :priority => '40') - Entry.parse('1.2.3.4 www.example.com # @40') - end + it 'sets @calculated_priority to false' do + subject.priority = 50 + expect(subject.instance_variable_get(:@calculated_priority)).to be_false end end - describe '#to_s' do + describe '#to_line' do context 'without a comment' do subject { Entry.new(:ip_address => '2.3.4.5', :hostname => 'www.example.com') } it 'prints without aliases' do - expect(subject.to_s).to eq("2.3.4.5\twww.example.com") + expect(subject.to_line).to eq("2.3.4.5\twww.example.com") end it 'prints with aliases' do subject.aliases << 'foo' - expect(subject.to_s).to eq("2.3.4.5\twww.example.com foo") + expect(subject.to_line).to eq("2.3.4.5\twww.example.com foo") end it 'prints out the priority' do subject.priority = 10 - expect(subject.to_s).to eq("2.3.4.5\twww.example.com\t# @10") + expect(subject.to_line).to eq("2.3.4.5\twww.example.com\t# @10") end end @@ -132,19 +146,64 @@ subject { Entry.new(:ip_address => '2.3.4.5', :hostname => 'www.example.com', :comment => 'This is a comment!') } it 'prints without aliases' do - expect(subject.to_s).to eq("2.3.4.5\twww.example.com\t# This is a comment!") + expect(subject.to_line).to eq("2.3.4.5\twww.example.com\t# This is a comment!") end it 'prints with aliases' do subject.aliases << 'foo' - expect(subject.to_s).to eq("2.3.4.5\twww.example.com foo\t# This is a comment!") + expect(subject.to_line).to eq("2.3.4.5\twww.example.com foo\t# This is a comment!") end it 'prints out the priority' do subject.priority = 10 - expect(subject.to_s).to eq("2.3.4.5\twww.example.com\t# This is a comment! @10") + expect(subject.to_line).to eq("2.3.4.5\twww.example.com\t# This is a comment! @10") end + end + end + + describe '#to_s' do + subject { Entry.new(:ip_address => '2.3.4.5', :hostname => 'www.example.com') } + + it 'prints correctly' do + expect(subject.to_s).to eq("#") + end + + it 'prints without aliases' do + subject.aliases << 'foo' + expect(subject.to_s).to eq("#") + end + + it 'prints without a priority' do + subject.priority = 10 + expect(subject.to_s).to eq("#") + end + + it 'prints without comments' do + subject.comment = "This is a comment" + expect(subject.to_s).to eq("#") + end + end + + describe '#inspect' do + subject { Entry.new(:ip_address => '2.3.4.5', :hostname => 'www.example.com') } + + it 'prints correctly' do + expect(subject.inspect).to eq("#") + end + + it 'prints with aliases' do + subject.aliases << 'foo' + expect(subject.inspect).to eq("#") + end + + it 'prints with a priority' do + subject.priority = 10 + expect(subject.inspect).to eq("#") + end + it 'prints with comments' do + subject.comment = "This is a comment" + expect(subject.inspect).to eq("#") end end end diff --git a/spec/unit/manipulator_spec.rb b/spec/unit/manipulator_spec.rb index 2072647a..b3765291 100644 --- a/spec/unit/manipulator_spec.rb +++ b/spec/unit/manipulator_spec.rb @@ -1,5 +1,256 @@ require 'spec_helper' describe Manipulator do - pending + let(:node) { double('node', :to_hash => {:foo => 'bar'}) } + + let(:lines) do + [ + "127.0.0.1 localhost", + "1.2.3.4 example.com", + "4.5.6.7 foo.example.com" + ] + end + + let(:entries) do + [ + double('entry_1', :ip_address => '127.0.0.1', :hostname => 'localhost', :to_line => '127.0.0.1 localhost'), + double('entry_2', :ip_address => '1.2.3.4', :hostname => 'example.com', :to_line => '1.2.3.4 example.com'), + double('entry_3', :ip_address => '4.5.6.7', :hostname => 'foo.example.com', :to_line => '4.5.6.7 foo.example.com') + ] + end + + before do + File.stub(:exists?).and_return(true) + File.stub(:readlines).and_return(lines) + manipulator.instance_variable_set(:@entries, entries) + end + + let(:manipulator) { Manipulator.new(node) } + + describe '.initialize' do + it 'saves the given node to a hash' do + node.should_receive(:to_hash).once + Manipulator.new(node) + end + + it 'saves the node hash to an instance variable' do + manipulator = Manipulator.new(node) + expect(manipulator.node).to eq(node.to_hash) + end + + it 'raises a fatal error if the hostsfile does not exist' do + File.stub(:exists?).and_return(false) + Chef::Application.should_receive(:fatal!).once.and_raise(SystemExit) + expect { + Manipulator.new(node) + }.to raise_error SystemExit + end + + it 'sends the line to be parsed by the Entry class' do + lines.each { |l| Entry.should_receive(:parse).with(l) } + Manipulator.new(node) + end + end + + describe '#ip_addresses' do + it 'returns a list of all the IP Addresses' do + expect(manipulator.ip_addresses).to eq(entries.map(&:ip_address)) + end + end + + describe '#add' do + let(:entry) { double('entry') } + + let(:options) { { :ip_address => '1.2.3.4', :hostname => 'example.com', :aliases => nil, :comment => 'Some comment', :priority => 5 } } + + before { Entry.stub(:new).and_return(entry) } + + it 'creates a new entry object' do + Entry.should_receive(:new).with(options) + manipulator.add(options) + end + + it 'pushes the new entry onto the collection' do + manipulator.add(options) + expect(manipulator.instance_variable_get(:@entries)).to include(entry) + end + end + + describe '#update' do + context 'when the entry does not exist' do + before do + manipulator.stub(:find_entry_by_ip_address).with(any_args()).and_return(nil) + end + + it 'does nothing' do + manipulator.update(:ip_address => '5.4.3.2', :hostname => 'seth.com') + expect(manipulator.instance_variable_get(:@entries)).to eq(entries) + end + end + + context 'with the entry does exist' do + let(:entry) { double('entry', :hostname= => nil, :aliases= => nil, :comment= => nil, :priority= => nil) } + + before do + manipulator.stub(:find_entry_by_ip_address).with(any_args()).and_return(entry) + end + + it 'updates the hostname' do + entry.should_receive(:hostname=).with('seth.com') + manipulator.update(:ip_address => '1.2.3.4', :hostname => 'seth.com') + end + end + end + + describe '#append' do + let(:options) { { :ip_address => '1.2.3.4', :hostname => 'example.com', :aliases => nil, :comment => 'Some comment', :priority => 5 } } + + context 'when the record exists' do + let(:entry) { double('entry', options.merge(:aliases= => nil, :comment= => nil)) } + + before do + manipulator.stub(:find_entry_by_ip_address).with(any_args()).and_return(entry) + end + + it 'updates the aliases' do + entry.should_receive(:aliases=).with(['example.com']) + manipulator.append(options) + end + + it 'updates the comment' do + entry.should_receive(:comment=).with('Some comment, This is a new comment!') + manipulator.append(options.merge(:comment => 'This is a new comment!')) + end + end + + context 'when the record does not exist' do + before do + manipulator.stub(:find_entry_by_ip_address).with(any_args()).and_return(nil) + manipulator.stub(:add) + end + + it 'delegates to #add' do + manipulator.should_receive(:add).with(options).once + manipulator.append(options) + end + end + end + + describe '#remove' do + context 'when the entry does not exist' do + before do + manipulator.stub(:find_entry_by_ip_address).with(any_args()).and_return(nil) + end + + it 'does nothing' do + manipulator.remove('5.4.3.2') + expect(manipulator.instance_variable_get(:@entries)).to eq(entries) + end + end + + context 'with the entry does exist' do + let(:entry) { entries[0] } + + before do + manipulator.stub(:find_entry_by_ip_address).with(any_args()).and_return(entry) + end + + it 'removes the entry' do + expect(manipulator.instance_variable_get(:@entries)).to include(entry) + manipulator.remove('5.4.3.2') + expect(manipulator.instance_variable_get(:@entries)).to_not include(entry) + end + end + end + + describe '#save' do + it 'delegates to #save! and returns true' do + manipulator.stub(:save!) + manipulator.should_receive(:save!).once + expect(manipulator.save).to be_true + end + + it 'returns false if an exception is raised' do + manipulator.stub(:save!).and_raise(Exception) + manipulator.should_receive(:save!).once + expect(manipulator.save).to be_false + end + end + + describe '#save!' do + let(:f) { stub('f', :write => true) } + + before do + File.stub(:open).and_yield(f) + manipulator.stub(:unique_entries).and_return(entries) + end + + context 'when the file has not changed' do + it 'does not write out the file' do + Digest::SHA512.stub(:hexdigest).and_return('abc123') + File.should_not_receive(:open) + manipulator.save! + end + end + + context 'when the file has changed' do + it 'writes out the new file' do + File.should_receive(:open).with('/etc/hosts', 'w').once + f.should_receive(:write).once + manipulator.save! + end + end + end + + describe '#find_entry_by_ip_address' do + it 'finds the associated entry' do + expect(manipulator.find_entry_by_ip_address('127.0.0.1')).to eq(entries[0]) + expect(manipulator.find_entry_by_ip_address('1.2.3.4')).to eq(entries[1]) + expect(manipulator.find_entry_by_ip_address('4.5.6.7')).to eq(entries[2]) + end + + it 'returns nil if the entry does not exist' do + expect(manipulator.find_entry_by_ip_address('77.77.77.77')).to be_nil + end + end + + # Private Methods + # ------------------------- + describe '#hostsfile_path' do + before { Manipulator.send(:public, :hostsfile_path) } + + context 'on Windows' do + let(:node) do + { + 'platform_family' => 'windows', + 'kernel' => { + 'os_info' => { + 'system_directory' => 'C:\Windows\system32' + } + } + } + end + + it 'returns the Windows path' do + expect(manipulator.hostsfile_path).to eq('C:\\Windows\\system32\\drivers\\etc\\hosts') + end + end + + context 'everywhere else' do + it 'returns the Linux path' do + expect(manipulator.hostsfile_path).to eq('/etc/hosts') + end + end + end + + describe '#current_sha' do + before do + Manipulator.send(:public, :current_sha) + File.stub(:read).with('/etc/hosts').and_return('abc123') + end + + it 'returns the SHA of the current hostsfile' do + expect(manipulator.current_sha).to eq('c70b5dd9ebfb6f51d09d4132b7170c9d20750a7852f00680f65658f0310e810056e6763c34c9a00b0e940076f54495c169fc2302cceb312039271c43469507dc') + end + end end