diff --git a/modules/libs/lib/facter/subnet_facts.rb b/modules/libs/lib/facter/subnet_facts.rb index b53bed5..8669fe1 100644 --- a/modules/libs/lib/facter/subnet_facts.rb +++ b/modules/libs/lib/facter/subnet_facts.rb @@ -30,21 +30,37 @@ class SubnetAttributes # Default attributes if no subnet matches, also defined as a constant DEFAULT_ATTRIBUTES = { environment: 'unknown', region: 'unknown', country: 'unknown' }.freeze - # provide ip to return attributes - def self.attributes(ip) - SUBNET_TO_ATTRIBUTES.each do |subnet, attrs| - return attrs if IPAddr.new(subnet).include?(IPAddr.new(ip)) - end + # loopbacks and wg /31s sit in syd1 ranges, so they must not decide where a host lives + SKIPPED_INTERFACES = /\A(lo\z|dum|wg)/.freeze - DEFAULT_ATTRIBUTES + # primary ip first, then the remaining interfaces in name order + def self.from_networking(networking) + match(networking['ip']) || interface_match(networking['interfaces'] || {}) || DEFAULT_ATTRIBUTES + end + + def self.interface_match(interfaces) + interfaces.sort + .reject { |name, _interface| name.match?(SKIPPED_INTERFACES) } + .flat_map { |_name, interface| addresses(interface) } + .lazy.filter_map { |address| match(address) }.first + end + + def self.addresses(interface) + (interface['bindings'] || []).map { |binding| binding['address'] } + end + + def self.match(ip) + return unless ip + + addr = IPAddr.new(ip) + SUBNET_TO_ATTRIBUTES.each do |subnet, attrs| + return attrs if IPAddr.new(subnet).include?(addr) + end + nil end end -# Use the primary IP address from facts -ip = Facter.value(:networking)['ip'] - -# Call the class method directly without creating an instance -subnet_attributes = SubnetAttributes.attributes(ip) +subnet_attributes = SubnetAttributes.from_networking(Facter.value(:networking)) # Add separate facts for environment, region, and country Facter.add('environment') { setcode { subnet_attributes[:environment] } } diff --git a/modules/libs/test/subnet_facts_check.rb b/modules/libs/test/subnet_facts_check.rb new file mode 100644 index 0000000..eff55d6 --- /dev/null +++ b/modules/libs/test/subnet_facts_check.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# ruby modules/libs/test/subnet_facts_check.rb +module Facter + def self.value(_name) + {} + end + + def self.add(*); end +end +load File.expand_path('../lib/facter/subnet_facts.rb', __dir__) + +iface = ->(*addresses) { { 'bindings' => addresses.map { |address| { 'address' => address } } } } + +checks = { + 'primary ip wins over other interfaces' => [ + { 'ip' => '198.18.17.5', 'interfaces' => { 'eth1' => iface.call('198.18.13.5') } }, 'drw1' + ], + 'unmatched primary ip falls back to an interface' => [ + { 'ip' => '203.0.113.5', 'interfaces' => { 'eth0' => iface.call('203.0.113.5'), 'eth1' => iface.call('198.18.24.11') } }, 'syd1' + ], + 'lo, dum and wg interfaces are skipped' => [ + { 'ip' => '203.0.113.5', + 'interfaces' => { 'dum0' => iface.call('198.18.2.161'), 'lo' => iface.call('198.18.19.1'), + 'wg0' => iface.call('198.18.2.177') } }, 'unknown' + ], + 'interfaces are scanned in name order' => [ + { 'ip' => '203.0.113.5', 'interfaces' => { 'eth2' => iface.call('198.18.24.1'), 'eth1' => iface.call('198.18.17.1') } }, 'drw1' + ], + 'missing ip and interfaces resolve to unknown' => [{}, 'unknown'] +} + +failures = checks.reject { |_name, (networking, expected)| SubnetAttributes.from_networking(networking)[:region] == expected } +failures.each_key { |name| warn "FAIL: #{name}" } +abort if failures.any? +puts "ok (#{checks.size} checks)"