Skip to content

Commit

Permalink
Fixes #32518 - Moving Ansible fact parser
Browse files Browse the repository at this point in the history
Moving Ansible fact parser from Ansible plugin to Core for better refactoring of parsers logic
  • Loading branch information
domitea authored and ekohl committed Jun 16, 2021
1 parent 416a52e commit bd68827
Show file tree
Hide file tree
Showing 11 changed files with 1,187 additions and 0 deletions.
Binary file added app/assets/images/Ansible.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions app/models/fact_names/foreman_ansible/fact_name.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

module ForemanAnsible
# Define the class that fact names that come from Ansible should have
# It allows us to filter facts by origin, and also to display the origin
# in the fact values table (/fact_values)
class FactName < ::FactName
def origin
'Ansible'
end

def icon_path
'Ansible.png'
end
end
end
126 changes: 126 additions & 0 deletions app/services/ansible_fact_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# frozen_string_literal: true

# Override methods from Foreman app/services/fact_parser so that facts
# representing host properties are understood when they come from Ansible.
class AnsibleFactParser < FactParser
include ForemanAnsible::OperatingSystemParser
attr_reader :facts

def initialize(facts)
@facts = HashWithIndifferentAccess.new(facts[:ansible_facts])
end

# Don't do anything as there's no env in Ansible
def environment
end

def architecture
name = facts[:ansible_architecture] || facts[:facter_architecture]
Architecture.where(:name => name).first_or_create if name.present?
end

def model
name = detect_fact([:ansible_product_name, :facter_virtual,
:facter_productname, :facter_model, :model])
Model.where(:name => name.strip).first_or_create if name.present?
end

def domain
name = detect_fact([:ansible_domain, :facter_domain,
:ohai_domain, :domain])
Domain.where(:name => name).first_or_create if name.present?
end

def support_interfaces_parsing?
true
end

# Move ansible's default interface first in the list of interfaces since
# Foreman picks the first one that is usable. If ansible has no
# preference otherwise at least sort the list.
#
# This method overrides app/services/fact_parser.rb on Foreman and returns
# an array of interface names, ['eth0', 'wlan1', etc...]
def get_interfaces
pref = facts[:ansible_default_ipv4] &&
facts[:ansible_default_ipv4]['interface']
if pref.present?
(facts[:ansible_interfaces] - [pref]).unshift(pref)
else
ansible_interfaces
end
end

def get_facts_for_interface(iface_name)
interface = iface_name.tr('-', '_') # virbr1-nic -> virbr1_nic
interface_facts = facts[:"ansible_#{interface}"]
ipaddress = ip_from_interface(interface)
ipaddress6 = ipv6_from_interface(interface)
macaddress = mac_from_interface(interface)
iface_facts = HashWithIndifferentAccess[
interface_facts.merge(:ipaddress => ipaddress,
:ipaddress6 => ipaddress6,
:macaddress => macaddress)
]
logger.debug { "Ansible interface #{interface} facts: #{iface_facts.inspect}" }
iface_facts
end

def ipmi_interface
end

def boot_timestamp
Time.zone.now.to_i - facts['ansible_uptime_seconds'].to_i
end

def virtual
facts['ansible_virtualization_role'] == 'guest'
end

def ram
facts['ansible_memtotal_mb'].to_i
end

def sockets
facts['ansible_processor_count'].to_i
end

def cores
facts['ansible_processor_cores'].to_i
end

private

def ansible_interfaces
return [] if facts[:ansible_interfaces].blank?
facts[:ansible_interfaces].sort
end

def mac_from_interface(interface)
facts[:"ansible_#{interface}"]['perm_macaddress'].presence || facts[:"ansible_#{interface}"]['macaddress']
end

def ip_from_interface(interface)
return if facts[:"ansible_#{interface}"]['ipv4'].blank?
if facts[:"ansible_#{interface}"]['ipv4'].is_a?(Array)
facts[:"ansible_#{interface}"]['ipv4'][0]['address']
else
facts[:"ansible_#{interface}"]['ipv4']['address']
end
end

def ipv6_from_interface(interface)
return if facts[:"ansible_#{interface}"]['ipv6'].blank?

facts[:"ansible_#{interface}"]['ipv6'].first['address']
end

# Returns first non-empty fact. Needed to check for empty strings.
def detect_fact(fact_names)
facts[
fact_names.detect do |fact_name|
facts[fact_name].present?
end
]
end
end
37 changes: 37 additions & 0 deletions app/services/foreman_ansible/fact_sparser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# frozen_string_literal: true

module ForemanAnsible
# See sparse and unsparse documentation
class FactSparser
class << self
# Sparses facts, so that it converts a facts hash
# { operatingsystem : { major: 20, name : 'fedora' }
# into
# { operatingsystem::major: 20,
# operatingsystem::name: 'fedora' }
def sparse(hash, options = {})
hash.map do |k, v|
prefix = options.fetch(:prefix, []) + [k]
next sparse(v, options.merge(:prefix => prefix)) if v.is_a? Hash
{ prefix.join(options.fetch(:separator, FactName::SEPARATOR)) => v }
end.reduce(:merge) || {}
end

# Unsparses facts, so that it converts a hash with facts
# { operatingsystem::major: 20,
# operatingsystem::name: 'fedora' }
# into
# { operatingsystem : { major: 20, name: 'fedora' } }
def unsparse(facts_hash)
ret = {}
sparse(facts_hash).each do |full_name, value|
current = ret
fact_name = full_name.to_s.split(FactName::SEPARATOR)
current = (current[fact_name.shift] ||= {}) until fact_name.size <= 1
current[fact_name.first] = value
end
ret
end
end
end
end
102 changes: 102 additions & 0 deletions app/services/foreman_ansible/operating_system_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# frozen_string_literal: true

module ForemanAnsible
# Methods to parse facts related to the OS
module OperatingSystemParser
def operatingsystem
args = { :name => os_name, :major => os_major, :minor => os_minor }
args[:release_name] = os_release_name if os_name == 'Debian' || os_name == 'Ubuntu'
return @local_os if local_os(args).present?
return @new_os if new_os(args).present?
logger.debug do
'Ansible facts parser: No OS could be created with '\
"os_name='#{os_name}' os_major='#{os_major}' "\
"os_minor='#{os_minor}': "\
"#{@new_os.errors if @new_os.present?}"
end
nil
end

def local_os(args)
@local_os = Operatingsystem.where(args).first
end

def new_os(args)
return @new_os if @new_os.present?
@new_os = Operatingsystem.new(args.merge(:description => os_description))
@new_os if @new_os.valid? && @new_os.save
end

def debian_os_major_sid
case facts[:ansible_distribution_major_version]
when /wheezy/i
'7'
when /jessie/i
'8'
when /stretch/i
'9'
when /buster/i
'10'
end
end

def os_release_name
return '' if os_name != 'Debian' && os_name != 'Ubuntu'
facts[:ansible_distribution_release]
end

def os_major
if os_name == 'Debian' &&
facts[:ansible_distribution_major_version][%r{\/sid}i]
debian_os_major_sid
else
facts[:ansible_distribution_major_version] ||
facts[:ansible_lsb] && facts[:ansible_lsb]['major_release'] ||
(facts[:version].split('R')[0] if os_name == 'junos')
end
end

def os_release
facts[:ansible_distribution_version] ||
facts[:ansible_lsb] && facts[:ansible_lsb]['release']
end

def os_minor
_, minor = os_release&.split('.', 2) ||
(facts[:version].split('R') if os_name == 'junos')
# Until Foreman supports os.minor as something that's not a number,
# we should remove the extra dots in the version. E.g:
# '6.1.7601.65536' becomes '6.1.760165536'
if facts[:ansible_os_family] == 'Windows'
minor, patch = minor.split('.', 2)
patch.tr!('.', '')
minor = "#{minor}.#{patch}"
end
minor || ''
end

def os_name
if facts[:ansible_os_family] == 'Windows'
facts[:ansible_os_name].tr(" \n\t", '') ||
facts[:ansible_distribution].tr(" \n\t", '')
else
distribution = facts[:ansible_lsb].try(:[], 'id') || facts[:ansible_distribution]

if distribution == 'RedHat' &&
facts[:ansible_lsb].try(:[], 'id') == 'RedHatEnterpriseWorkstation'
distribution += '_Workstation'
end

distribution
end
end

def os_description
if facts[:ansible_os_family] == 'Windows'
facts[:ansible_os_name].strip || facts[:ansible_distribution].strip
else
facts[:ansible_lsb] && facts[:ansible_lsb]['description']
end
end
end
end
25 changes: 25 additions & 0 deletions app/services/foreman_ansible/structured_fact_importer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

module ForemanAnsible
# On 1.13+ , use the parser for structured facts (like Facter 2) that comes
# from core
class StructuredFactImporter < ::StructuredFactImporter
def fact_name_class
ForemanAnsible::FactName
end

def self.authorized_smart_proxy_features
'Ansible'
end

def initialize(host, facts = {})
# Try to assign these facts to the correct host as per the facts say
# If that host isn't created yet, the host parameter will contain it
@host = Host.find_by(:name => facts[:ansible_facts][:ansible_fqdn] ||
facts[:ansible_facts][:fqdn]) ||
host
@facts = normalize(facts[:ansible_facts])
@counters = {}
end
end
end
13 changes: 13 additions & 0 deletions config/initializers/z_plugin_parsers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class ParserRegistrator
def self.register_fact_parser(type, parser)
if (old_parser = FactParser.parser_for(type)) && old_parser != FactParser.parsers.default
Rails.logger.warn("WARNING: Parser #{old_parser} for type #{type} is replaced with #{parser}")
end

FactParser.register_fact_parser(type, parser)
end
end

# Ansible
Foreman::Plugin.fact_importer_registry.register(:ansible, ForemanAnsible::StructuredFactImporter, false)
ParserRegistrator.register_fact_parser(:ansible, AnsibleFactParser)
Loading

0 comments on commit bd68827

Please sign in to comment.