diff --git a/.rubocop.yml b/.rubocop.yml index a165dc0..098a271 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -9,3 +9,5 @@ Style/SymbolArray: Lint/HandleExceptions: Exclude: - 'spec/palletjack-tool_spec.rb' +Style/Not: + Enabled: false diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 75f9d0f..49d1a73 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -24,6 +24,7 @@ Lint/AssignmentInCondition: # SupportedStylesAlignWith: either, start_of_block, start_of_line Lint/BlockAlignment: Exclude: + - 'tools/exe/palletjack2ipahost' - 'tools/spec/palletjack2kea_spec.rb' # Offense count: 1 @@ -36,6 +37,10 @@ Lint/NonLocalExitFromIterator: Exclude: - 'lib/palletjack/keytransformer.rb' +Lint/ShadowingOuterLocalVariable: + Exclude: + - 'tools/exe/palletjack2ipahost' + # Offense count: 1 # Cop supports --auto-correct. # Configuration parameters: IgnoreEmptyBlocks, AllowUnusedKeywordArguments. @@ -60,6 +65,7 @@ Lint/UselessAccessModifier: Lint/UselessAssignment: Exclude: - 'lib/palletjack/tool.rb' + - 'tools/exe/palletjack2ipahost' - 'tools/exe/palletjack2unbound' # Offense count: 16 @@ -157,6 +163,7 @@ Style/BlockDelimiters: - 'tools/exe/create_ipv4_interface' - 'tools/exe/create_system' - 'tools/exe/dump_pallet' + - 'tools/exe/palletjack2ipahost' - 'tools/exe/palletjack2unbound' - 'tools/lib/palletjack2zones.rb' - 'tools/spec/palletjack2kea_spec.rb' @@ -223,15 +230,8 @@ Style/Documentation: - 'lib/palletjack/pallet/identity.rb' - 'lib/palletjack/tool.rb' - 'lib/traceable.rb' - - 'tools/exe/create_domain' - - 'tools/exe/create_ipv4_interface' - - 'tools/exe/create_system' - - 'tools/exe/dump_pallet' - - 'tools/exe/palletjack2kea' - - 'tools/exe/palletjack2pxelinux' - - 'tools/exe/palletjack2salt' - - 'tools/exe/palletjack2unbound' - - 'tools/lib/palletjack2zones.rb' + - 'tools/exe/*' + - 'tools/lib/*' # Offense count: 1 # Cop supports --auto-correct. @@ -283,6 +283,7 @@ Layout/FirstParameterIndentation: - 'tools/exe/create_domain' - 'tools/exe/create_ipv4_interface' - 'tools/exe/create_system' + - 'tools/exe/palletjack2ipahost' - 'tools/exe/palletjack2kea' - 'tools/exe/palletjack2pxelinux' - 'tools/exe/palletjack2salt' @@ -318,6 +319,7 @@ Style/GlobalVars: Style/GuardClause: Exclude: - 'lib/traceable.rb' + - 'tools/exe/palletjack2ipahost' - 'tools/exe/palletjack2salt' # Offense count: 3 @@ -336,17 +338,7 @@ Style/HashSyntax: # Configuration parameters: MaxLineLength. Style/IfUnlessModifier: Exclude: - - 'tools/exe/create_domain' - - 'tools/exe/create_ipv4_interface' - - 'tools/exe/create_system' - - 'tools/exe/dump_pallet' - - 'tools/exe/palletjack2kea' - - 'tools/exe/palletjack2knot1' - - 'tools/exe/palletjack2knot2' - - 'tools/exe/palletjack2pxelinux' - - 'tools/exe/palletjack2salt' - - 'tools/exe/palletjack2unbound' - - 'tools/lib/palletjack2zones.rb' + - 'tools/exe/*' # Offense count: 16 # Cop supports --auto-correct. @@ -357,6 +349,7 @@ Layout/IndentAssignment: - 'tools/exe/create_domain' - 'tools/exe/create_ipv4_interface' - 'tools/exe/create_system' + - 'tools/exe/palletjack2ipahost' - 'tools/exe/palletjack2kea' - 'tools/exe/palletjack2pxelinux' - 'tools/exe/palletjack2salt' @@ -398,14 +391,6 @@ Style/MultilineIfModifier: Exclude: - 'tools/exe/palletjack2unbound' -# Offense count: 5 -# Cop supports --auto-correct. -Style/MultilineIfThen: - Exclude: - - 'lib/palletjack/keytransformer.rb' - - 'lib/palletjack/tool.rb' - - 'tools/exe/palletjack2unbound' - # Offense count: 6 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. @@ -501,6 +486,7 @@ Layout/SpaceInsideBlockBraces: - 'tools/exe/create_ipv4_interface' - 'tools/exe/create_system' - 'tools/exe/dump_pallet' + - 'tools/exe/palletjack2ipahost' - 'tools/exe/palletjack2kea' - 'tools/exe/palletjack2pxelinux' - 'tools/exe/palletjack2salt' diff --git a/examples/warehouse/system/README b/examples/warehouse/system/README index 345813d..c8c9c4c 100644 --- a/examples/warehouse/system/README +++ b/examples/warehouse/system/README @@ -17,14 +17,21 @@ physical machine, a virtual machine or a container. The link "os" points to the operating system this system runs. role.yaml contains, in the key "system.role", a list of roles which -this system skall have, which in the future will be made available to -the configuration management system. +this system skall have, which will be made available to the +configuration management system. It also contains, in the key +"system.ipa.hostgroups", a list of hostgroups the system should join +in a FreeIPA domain. + +ipa.yaml is updated by the palletjack2ipahost tool and contains, in +the key "system.ipa.otp", a one-time password which can be used to +enroll in a FreeIPA domain. Files: system//architecture.yaml system//role.yaml + system//ipa.yaml Links: @@ -46,6 +53,15 @@ role.yaml: - role1 - role2 ... + ipa: + hostgroups: + - group1 + ... + +ipa.yaml: + system: + ipa: + otp: Auto-generated FreeIPA password Transformed values: diff --git a/examples/warehouse/system/testvm/role.yaml b/examples/warehouse/system/testvm/role.yaml index 641489a..ad79c94 100644 --- a/examples/warehouse/system/testvm/role.yaml +++ b/examples/warehouse/system/testvm/role.yaml @@ -2,3 +2,7 @@ system: role: - web-server - ssh-server + - freeipa-client + ipa: + hostgroups: + - clients diff --git a/lib/palletjack/keytransformer.rb b/lib/palletjack/keytransformer.rb index aaf11cd..0739168 100644 --- a/lib/palletjack/keytransformer.rb +++ b/lib/palletjack/keytransformer.rb @@ -49,7 +49,7 @@ def synthesize_internal(param, dictionary, result = String.new) case param when String rex = /#\[([[:alnum:]._-]+)\]/ - if md = rex.match(param) then + if md = rex.match(param) result << md.pre_match return unless lookup = dictionary[md[1]] result << lookup.to_s @@ -249,9 +249,8 @@ def transform!(pallet) transforms.each do |t| transform, param = t.flatten - if self.respond_to?(transform.to_sym) then + if self.respond_to?(transform.to_sym) if new_value = self.send(transform.to_sym, param, context) - then new_value = TraceableString.new(new_value) new_value.file = transform.file new_value.line = transform.line diff --git a/lib/palletjack/tool.rb b/lib/palletjack/tool.rb index 5b6ee55..5716e23 100644 --- a/lib/palletjack/tool.rb +++ b/lib/palletjack/tool.rb @@ -457,7 +457,6 @@ def git_header(tool_name, comment_char: '#', include_id: false) #{comment_char}#{comment_char} Repository: #{workdir} #{comment_char}#{comment_char} Branch: #{branch}\n" if include_id - then header += "#{comment_char}#{comment_char} Commit ID: #{repo.head.target_id}\n" end diff --git a/tools/exe/palletjack2ipahost b/tools/exe/palletjack2ipahost new file mode 100755 index 0000000..927ddec --- /dev/null +++ b/tools/exe/palletjack2ipahost @@ -0,0 +1,192 @@ +#!/usr/bin/env ruby + +# Create host objects in a FreeIPA instance. +# +# Requires you to first have a valid Kerberos ticket for a FreeIPA +# administrator. Check if you have one with "klist" and if not, get +# one using "kinit ". +# +# Runs for a single system or, if none is given, every system in the +# warehouse with the role freeipa-client. +# +# Hostgroups are synchronized with the warehouse and any group not +# specified in the warehouse will be removed. +# +# Host objects will be created with a randomly generated password. The +# password is saved in the warehouse as system.ipa.otp, for later +# export to Salt. +# +# If the --reinstall parameter is specified, the existing host object +# will be removed and a new one created, allowing the machine be be +# reinstalled. + +require 'palletjack/tool' +require 'ipa/client' +require 'yaml' +require 'hashie' + +class PalletJack2IPAHost < PalletJack::Tool + def parse_options(opts) + opts.banner = +"Usage: #{$PROGRAM_NAME} -w -i [-s [--reinstall]] [-v] + +Synchronize systems in the warehouse to host objects in an IPA server. + +Requres a valid Kerberos ticket for a FreeIPA administrator." + + opts.on('-i SERVER', '--ipa-server SERVER', 'IPA server', + String) { |srv| options[:ipa] = srv } + opts.on('-s SYSTEM', '--system SYSTEM', 'system to synchronize', + String) { |system| options[:system] = system } + opts.on('-r', '--reinstall', 'reinstall the named system') { |reinstall| + required_option :system + options[:reinstall] = reinstall + } + opts.on('-v', '--verbose', 'verbose mode') { |verbose| + options[:verbose] = verbose + } + + required_option :ipa + end + + def output + # Do all the processing here in #output, since we're always + # modifying state on an external FreeIPA server and there is no + # local preprocessing to do in #process. + + @ipa_boxes = {} + + connect_to_freeipa(options[:ipa]) + + if options[:system] + system = jack.fetch(kind: 'system', name: options[:system]) + unless system['system.role'].include? 'freeipa-client' + raise "System \"#{options[:system]}\" does not have the role \"freeipa-client\"" + end + sync_system system + else + jack.each(kind: 'system') do |system| + next unless system['system.role'].include? 'freeipa-client' + sync_system system + end + end + + @ipa_boxes.each do |system, content| + pallet_box 'system', system, 'ipa' do + { system: { ipa: content } } + end + end + end + + private + + def connect_to_freeipa(host) + @ipa = IPA::Client.new(host: host) + end + + # Update all FreeIPA server state to match the +system+ from the warehouse + # + # If reinstalling, remove the old host object. Then, create a new + # host object and sync hostgroups. + def sync_system(system) + puts "Syncing #{system['system.name']}" if options[:verbose] + + box = system['system.ipa'] || {} + + if options[:reinstall] + puts 'Removing host object' if options[:verbose] + del_host = @ipa.host_del(hostname: system['net.dns.fqdn']) + check_api_error(del_host, 'deleting host') + end + + unless @ipa.host_exists? system['net.dns.fqdn'] + puts 'Creating host object' if options[:verbose] + box['otp'] = create_host_object(system) + end + + sync_hostgroups(system) + + @ipa_boxes[system['system.name']] = box + end + + # Create a host object in FreeIPA representing a +system+ from the + # warehouse. The host object may not already exist. + def create_host_object(system) + # Generate a random password + add_host_params = { 'random' => true } + + ip_address = nil + system.children(kind: 'ipv4_interface') do |interface| + # Find an interface in the same domain as the host and set as + # primary IP address in FreeIPA + if interface['net.dns.domain'] == system['net.dns.domain'] + ip_address = interface['net.ipv4.address'] + end + end + + if ip_address + add_host_params['ip_address'] = ip_address + puts "Using IP address #{ip_address}" if options[:verbose] + else + raise "No IP address in domain #{system['net.dns.domain']} found for #{system['system.name']}" + end + + add_host = @ipa.host_add(hostname: system['net.dns.fqdn'], params: add_host_params) + check_api_error(add_host, 'adding host') + + add_host['result']['result']['randompassword'] + end + + # Sync hostgroups from the warehouse to FreeIPA + # + # Join the system to the groups listed in the warehouse, and leave + # all groups not listed in the warehouse. + def sync_hostgroups(system) + host_show = @ipa.host_show(hostname: system['net.dns.fqdn'], all: true) + check_api_error(host_show, 'getting host information') + host_data = host_show['result']['result'] + + server_hostgroups = host_data['memberof_hostgroup'] || [] + warehouse_hostgroups = system['system.ipa.hostgroups'] || [] + + warehouse_hostgroups.each do |group| + next if server_hostgroups.include? group + puts "Adding hostgroup #{group}" if options[:verbose] + ipa_mod = @ipa.api_post(method: 'hostgroup_add_member', item: group, + params: { 'host' => [system['net.dns.fqdn']] }) + check_api_error(ipa_mod, 'adding host to group') + end + + server_hostgroups.each do |group| + next if warehouse_hostgroups.include? group + puts "Removing hostgroup #{group}" if options[:verbose] + ipa_mod = @ipa.api_post(method: 'hostgroup_remove_member', item: group, + params: { 'host' => [system['net.dns.fqdn']] }) + check_api_error(ipa_mod, 'removing host from group') + end + end + + # Check if the return value +ret+ from the +ipa/client+ API is an + # error or a failure, and if so, raise a RuntimeError including the + # string +action+. + def check_api_error(ret, action) + if ret['error'] + raise "Error #{action}: #{ret['error']['code']} #{ret['error']['name']}: #{ret['error']['message']}" + end + + # result.failed contains a complicated and variable structure with + # its leaves being lists of the requested operations that failed. + # If all operations were successful, all these lists are empty. + failed = ret['result']['failed'] + return unless failed + failed.extend Hashie::Extensions::DeepLocate + failures = failed.deep_locate lambda { |_, value, _| + value.is_a?(Array) && !value.empty? + } + raise "Failed #{action}: #{ret['result']['failed']}" unless failures.empty? + end +end + +if PalletJack2IPAHost.standalone?(__FILE__) + PalletJack2IPAHost.run +end diff --git a/tools/exe/palletjack2unbound b/tools/exe/palletjack2unbound index 451aae0..39933f7 100755 --- a/tools/exe/palletjack2unbound +++ b/tools/exe/palletjack2unbound @@ -51,7 +51,7 @@ stub-zone: stubfile << " stub-addr: #{addr}\n" end - if @transparent then + if @transparent stubfile << "\nserver:\n local-zone: \"#{@zone}\" transparent\n" end end diff --git a/tools/palletjack-tools.gemspec b/tools/palletjack-tools.gemspec index 56ef97f..4fb51d2 100644 --- a/tools/palletjack-tools.gemspec +++ b/tools/palletjack-tools.gemspec @@ -29,6 +29,8 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency 'dns-zone', '~> 0.3' spec.add_runtime_dependency 'ruby-ip', '~> 0.9' spec.add_runtime_dependency 'json', '~> 1.8' + spec.add_runtime_dependency 'ipa-ruby', '~> 0.0' + spec.add_runtime_dependency 'hashie', '~> 3' spec.add_development_dependency 'bundler', '~> 1.13' spec.add_development_dependency 'rake', '~> 10.0'