303 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
			
		
		
	
	
			303 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
#
 | 
						|
# Author:: Seth Vargo <sethvargo@gmail.com>
 | 
						|
# Cookbook:: hostsfile
 | 
						|
# Library:: manipulator
 | 
						|
#
 | 
						|
# Copyright:: 2012-2013, Seth Vargo
 | 
						|
# Copyright:: 2012, CustomInk, LCC
 | 
						|
#
 | 
						|
# Licensed under the Apache License, Version 2.0 (the "License");
 | 
						|
# you may not use this file except in compliance with the License.
 | 
						|
# You may obtain a copy of the License at
 | 
						|
#
 | 
						|
#     http://www.apache.org/licenses/LICENSE-2.0
 | 
						|
#
 | 
						|
# Unless required by applicable law or agreed to in writing, software
 | 
						|
# distributed under the License is distributed on an "AS IS" BASIS,
 | 
						|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
						|
# See the License for the specific language governing permissions and
 | 
						|
# limitations under the License.
 | 
						|
#
 | 
						|
 | 
						|
require 'chef/application'
 | 
						|
require 'openssl'
 | 
						|
 | 
						|
module HostsFile
 | 
						|
  class Manipulator
 | 
						|
    attr_reader :node
 | 
						|
    attr_reader :entries
 | 
						|
 | 
						|
    # Create a new Manipulator object (aka an /etc/hosts manipulator). If a
 | 
						|
    # hostsfile is not found, an exception is raised.
 | 
						|
    #
 | 
						|
    # @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
 | 
						|
 | 
						|
      # Fail if no hostsfile is found
 | 
						|
      unless ::File.exist?(hostsfile_path)
 | 
						|
        raise "No hostsfile exists at `#{hostsfile_path}'!"
 | 
						|
      end
 | 
						|
 | 
						|
      @entries = []
 | 
						|
      collect_and_flatten(::File.readlines(hostsfile_path))
 | 
						|
    end
 | 
						|
 | 
						|
    # Return a list of all IP Addresses for this hostsfile.
 | 
						|
    #
 | 
						|
    # @return [Array<IPAddr>]
 | 
						|
    #   the list of IP Addresses
 | 
						|
    def ip_addresses
 | 
						|
      @entries.collect(&:ip_address).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<String>] :aliases
 | 
						|
    #   a alias or array of aliases for this entry
 | 
						|
    # @option options[String] :comment
 | 
						|
    #   an optional comment for this entry
 | 
						|
    # @option options [Integer] :priority
 | 
						|
    #   the relative priority of this entry (compared to others)
 | 
						|
    def add(options = {})
 | 
						|
      entry = HostsFile::Entry.new(
 | 
						|
        ip_address: options[:ip_address],
 | 
						|
        hostname:   options[:hostname],
 | 
						|
        aliases:    options[:aliases],
 | 
						|
        comment:    options[:comment],
 | 
						|
        priority:   options[:priority]
 | 
						|
      )
 | 
						|
 | 
						|
      @entries << entry
 | 
						|
      remove_existing_hostnames(entry) if options[:unique]
 | 
						|
    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]
 | 
						|
 | 
						|
        remove_existing_hostnames(entry) if options[:unique]
 | 
						|
      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])
 | 
						|
        hosts          = normalize(entry.hostname, entry.aliases, options[:hostname], options[:aliases])
 | 
						|
        entry.hostname = hosts.shift
 | 
						|
        entry.aliases  = hosts
 | 
						|
 | 
						|
        unless entry.comment && options[:comment] && entry.comment.include?(options[:comment])
 | 
						|
          entry.comment = normalize(entry.comment, options[:comment]).join(', ')
 | 
						|
        end
 | 
						|
 | 
						|
        remove_existing_hostnames(entry) if options[:unique]
 | 
						|
      else
 | 
						|
        add(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
 | 
						|
 | 
						|
    # 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
 | 
						|
      file = Chef::Resource::File.new(hostsfile_path, node.run_context)
 | 
						|
      file.content(new_content)
 | 
						|
      file.atomic_update false if docker_guest?
 | 
						|
      file.run_action(:create)
 | 
						|
    end
 | 
						|
 | 
						|
    # Determine if the content of the hostfile has changed by comparing sha
 | 
						|
    # values of existing file and new content
 | 
						|
    #
 | 
						|
    # @return [Boolean]
 | 
						|
    def content_changed?
 | 
						|
      new_sha = OpenSSL::Digest::SHA512.hexdigest(new_content)
 | 
						|
      new_sha != current_sha
 | 
						|
    end
 | 
						|
 | 
						|
    # Find an entry by the given IP Address.
 | 
						|
    #
 | 
						|
    # @param [String] ip_address
 | 
						|
    #   the IP Address of the entry to find
 | 
						|
    # @return [Entry, nil]
 | 
						|
    #   the corresponding entry object, or nil if it does not exist
 | 
						|
    def find_entry_by_ip_address(ip_address)
 | 
						|
      @entries.find do |entry|
 | 
						|
        !entry.ip_address.nil? && entry.ip_address == ip_address
 | 
						|
      end
 | 
						|
    end
 | 
						|
 | 
						|
    # Determine if the current hostsfile contains the given resource. This
 | 
						|
    # is really just a proxy to {find_resource_by_ip_address} /
 | 
						|
    #
 | 
						|
    # @param [Chef::Resource] resource
 | 
						|
    #
 | 
						|
    # @return [Boolean]
 | 
						|
    def contains?(resource)
 | 
						|
      !!find_entry_by_ip_address(resource.ip_address)
 | 
						|
    end
 | 
						|
 | 
						|
    private
 | 
						|
 | 
						|
    # Determine if we are running inside a Docker container
 | 
						|
    #
 | 
						|
    # @return [Boolean]
 | 
						|
    def docker_guest?
 | 
						|
      node['virtualization'] && node['virtualization']['systems'] &&
 | 
						|
        node['virtualization']['systems']['docker'] && node['virtualization']['systems']['docker'] == 'guest'
 | 
						|
    end
 | 
						|
 | 
						|
    # The path to the current hostsfile.
 | 
						|
    #
 | 
						|
    # @return [String]
 | 
						|
    #   the full path to the hostsfile, depending on the operating system
 | 
						|
    #   can also be overriden in the node attributes
 | 
						|
    def hostsfile_path
 | 
						|
      return @hostsfile_path if @hostsfile_path
 | 
						|
      @hostsfile_path = node['hostsfile']['path'] || case node['platform_family']
 | 
						|
                                                     when 'windows'
 | 
						|
                                                       "#{node['kernel']['os_info']['system_directory']}\\drivers\\etc\\hosts"
 | 
						|
                                                     else
 | 
						|
                                                       '/etc/hosts'
 | 
						|
                                                     end
 | 
						|
    end
 | 
						|
 | 
						|
    # The header of the new hostsfile
 | 
						|
    #
 | 
						|
    # @return [Array]
 | 
						|
    #   an array of header comments
 | 
						|
    def hostsfile_header
 | 
						|
      lines = []
 | 
						|
      lines << '#'
 | 
						|
      lines << '# This file is managed by Chef, using the hostsfile cookbook.'
 | 
						|
      lines << '# Editing this file by hand is highly discouraged!'
 | 
						|
      lines << '#'
 | 
						|
      lines << '# Comments containing an @ sign should not be modified or else'
 | 
						|
      lines << '# hostsfile will be unable to guarantee relative priority in'
 | 
						|
      lines << '# future Chef runs!'
 | 
						|
      lines << '#'
 | 
						|
      lines << ''
 | 
						|
    end
 | 
						|
 | 
						|
    # The content that will be written to the hostfile
 | 
						|
    #
 | 
						|
    # @return [String]
 | 
						|
    #   the full contents of the hostfile to be written
 | 
						|
    def new_content
 | 
						|
      entries = hostsfile_header
 | 
						|
      entries += unique_entries.map(&:to_line)
 | 
						|
      entries << ''
 | 
						|
      entries.join("\n")
 | 
						|
    end
 | 
						|
 | 
						|
    # The current sha of the system hostsfile.
 | 
						|
    #
 | 
						|
    # @return [String]
 | 
						|
    #   the sha of the current hostsfile
 | 
						|
    def current_sha
 | 
						|
      @current_sha ||= OpenSSL::Digest::SHA512.hexdigest(File.read(hostsfile_path))
 | 
						|
    end
 | 
						|
 | 
						|
    # Normalize the given list of elements into a single array with no nil
 | 
						|
    # values and no duplicate values.
 | 
						|
    #
 | 
						|
    # @param [Object] things
 | 
						|
    #
 | 
						|
    # @return [Array]
 | 
						|
    #   a normalized array of things
 | 
						|
    def normalize(*things)
 | 
						|
      things.flatten.compact.uniq
 | 
						|
    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
 | 
						|
      entries = Hash[*@entries.map { |entry| [entry.ip_address, entry] }.flatten].values
 | 
						|
      entries.sort_by { |e| [-e.priority.to_i, e.hostname.to_s] }
 | 
						|
    end
 | 
						|
 | 
						|
    # Takes /etc/hosts file contents and builds a flattened entries
 | 
						|
    # array so that each IP address has only one line and multiple hostnames
 | 
						|
    # are flattened into a list of aliases.
 | 
						|
    #
 | 
						|
    # @param [Array] contents
 | 
						|
    #   Array of lines from /etc/hosts file
 | 
						|
    def collect_and_flatten(contents)
 | 
						|
      contents.each do |line|
 | 
						|
        entry = HostsFile::Entry.parse(line)
 | 
						|
        next if entry.nil?
 | 
						|
 | 
						|
        append(
 | 
						|
          ip_address: entry.ip_address,
 | 
						|
          hostname:   entry.hostname,
 | 
						|
          aliases:    entry.aliases,
 | 
						|
          comment:    entry.comment,
 | 
						|
          priority:   !entry.calculated_priority? && entry.priority
 | 
						|
        )
 | 
						|
      end
 | 
						|
    end
 | 
						|
 | 
						|
    # Removes duplicate hostnames in other files ensuring they are unique
 | 
						|
    #
 | 
						|
    # @param [Entry] entry
 | 
						|
    #   the entry to keep the hostname and aliases from
 | 
						|
    #
 | 
						|
    # @return [nil]
 | 
						|
    def remove_existing_hostnames(entry)
 | 
						|
      @entries.delete(entry)
 | 
						|
      changed_hostnames = [entry.hostname, 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
 | 
						|
        end
 | 
						|
      end.compact
 | 
						|
 | 
						|
      @entries << entry
 | 
						|
 | 
						|
      nil
 | 
						|
    end
 | 
						|
  end
 | 
						|
end
 |