2021-01-22 18:41:45 +01:00

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