diff --git a/.travis.yml b/.travis.yml index 0b50b86..5d0e8d4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ script: "bundle exec rake validate && bundle exec rake lint && bundle exec rake spec SPEC_OPTS='--format documentation'" language: ruby rvm: - - "2.1.6" + - "2.3.1" sudo: false diff --git a/Gemfile b/Gemfile index 6e06b01..4a86724 100644 --- a/Gemfile +++ b/Gemfile @@ -12,6 +12,4 @@ group :test do gem 'beaker-puppet_install_helper', :require => false gem 'net-dns' gem 'dnsruby' - gem 'rest-client' - end diff --git a/README.md b/README.md index d385b2e..ea81d0b 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This is a DNS record management module with support for creating records in DNSi ### Requirements -The gems 'rest-client' is needed to manage DynECT, while fog is needed to manage everything else except bind. +The 'fog' gem is needed to manage all providers except Bind. For Bind, you'll need to have a key configured for DDNS - https://wiki.debian.org/DDNS has more information @@ -45,19 +45,19 @@ dns_record { "test-2a-records.ops.puppetlabs.net": } dns_record { "test-cname.ops.puppetlabs.net": - domain => 'ops.puppetlabs.net', + ensure => present, + domain => 'ops.puppetlabs.net', content => 'test-1a-record.ops.puppetlabs.net', type => 'CNAME', ttl => '16000', - ensure => present } dns_record { "test-txt.ops.puppetlabs.net": - domain => 'ops.puppetlabs.net', + ensure => present, + domain => 'ops.puppetlabs.net', content => 'Test TXT Record', type => 'TXT', ttl => '32000', - ensure => present } ~~~ @@ -73,15 +73,25 @@ Dns_record { } dns_record { "test-1a-record.puppetware.org": - ensure => present + ensure => present, domain => 'puppetware.org', content => '172.16.100.150', type => 'A', ttl => '4800', } +# content can also accept array + +dns_record { "test-1a-record.puppetware.org": + ensure => present, + domain => 'puppetware.org', + content => ['172.16.100.150', '172.16.100.134'], + type => 'A', + ttl => '4800', +} + dns_record { "test-cname.puppetware.org": - ensure => present + ensure => present, domain => 'puppetware.org', content => 'test-1a-record.puppetware.org', type => 'CNAME', @@ -89,7 +99,7 @@ dns_record { "test-cname.puppetware.org": } dns_record { "test-txt.puppetware.org": - ensure => present + ensure => present, domain => 'puppetware.org', content => 'Test TXT Record', type => 'TXT', @@ -124,7 +134,7 @@ For the acceptance test, set up a few environment variables to ensure no issues. #####`type` *Required* The type of the DNS record. Accepts A, TXT, and CNAME for dynect, all types for bind9. #####`content` -*Required* The value of the DNS record. Can accept an array for bind9. +*Required* The value of the DNS record. Can accept an array for bind9 and DynECT. ## Limitations diff --git a/lib/puppet/feature/rest_client.rb b/lib/puppet/feature/rest_client.rb deleted file mode 100644 index 967e066..0000000 --- a/lib/puppet/feature/rest_client.rb +++ /dev/null @@ -1,7 +0,0 @@ -Puppet.features.add(:rest_client) do - begin - require 'rest_client' - rescue LoadError => e - warn "Gem 'rest-client' needed for DynECT. #{e}" - end -end diff --git a/lib/puppet/provider/dns_record/dynect.rb b/lib/puppet/provider/dns_record/dynect.rb index bbfe807..51571fb 100644 --- a/lib/puppet/provider/dns_record/dynect.rb +++ b/lib/puppet/provider/dns_record/dynect.rb @@ -1,9 +1,9 @@ -# Author:: Charles Dunbar +# Author:: Seekingalpha DevOps # Type Name:: dns_record # Provider:: dynect # -# Copyright 2015, Puppet Labs +# Copyright 2016, Seeking Alpha inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -18,178 +18,142 @@ # limitations under the License. # +require 'pp' + +module Dynect + module Connection + def dynect + @@dns ||= Fog::DNS.new({ + :provider => 'dynect', + :dynect_customer => @customername, + :dynect_username => @username, + :dynect_password => @password + }) + end + end +end + Puppet::Type.type(:dns_record).provide(:dynect) do desc "Manage DynECT records." - require 'json' - confine :feature => :rest_client - + confine :feature => :fog + include Dynect::Connection mk_resource_methods - - def self.instances - # Can't do anything - don't have credentials - end - - def self.get_token(resources) - url = "https://api.dynect.net/REST/Session/" - session_data = { :customer_name => resources.values[0][:customername], :user_name => resources.values[0][:username], :password => resources.values[0][:password] } - # Going to be reusing headers throughout the provider, has the auth_token - @headers = { "Content-Type" => 'application/json' } - response = RestClient.post(url,session_data.to_json,@headers) - # Parse and read the response, set new header to include auth code - obj = JSON.parse(response) - if obj['status'] == 'success' - auth_token = obj['data']['token'] - else - raise Puppet::Error, "Unable to authenticate to DynECT - check customer name, username and password are correct" - end - # Add Auth-Token to header - @headers = { "Content-Type" => 'application/json', 'Auth-Token' => auth_token } - end - - def self.prefetch(resources) - get_token(resources) - # Populate array with all resources - instances = [] - domains = [] - # Get unique list of domains and only grab those zones - resources.each do |name, resource| - domains << resource['domain'] unless domains.include?(resource['domain']) - end - domains.each do |dom| - url = "https://api.dynect.net/REST/AllRecord/#{dom}/" - response = RestClient.get(url,@headers) - obj = JSON.parse(response) - if obj['status'] != 'success' - #TODO error handle for dynect - end - instances << obj - end - resources.each do |name, resource| - Puppet.debug("prefetching for #{name}") - index = nil - objindex = nil - instances.each do |obj| - objindex = instances.index(obj) - # Find URL that contains the record ID, save it, and request more detailed information from dynect - index = obj['data'].index{|s| s.include?"#{resource[:type]}Record/#{resource[:domain]}/#{resource[:name]}"} - break unless index.nil? - end - if index.nil? - # Post and put used to determine if updating or creating a record - # Post == create, put == update - result = { :ensure => :absent} - result[:headers] = @headers - result[:ttl] = resource[:ttl] - result[:content] = resource[:content] - result[:type] = resource[:type] - result[:action] = "post" - resource.provider = new(result) - else - @url2 = "https://api.dynect.net#{instances[objindex]['data'][index]}" - response2 = RestClient.get(@url2,@headers) - obj2 = JSON.parse(response2) - if obj2['data']['fqdn'] == resource[:name] and obj2['data']['ttl'] == resource[:ttl].to_i and obj2['data']['rdata'] == set_rdata(resource) - result = { :ensure => :present } - result[:headers] = @headers - result[:url2] = @url2 - result[:ttl] = resource[:ttl] - result[:content] = resource[:content] - result[:type] = resource[:type] - resource.provider = new(result) - else - # Old data saved for puppet output - if obj2['data']['fqdn'] == resource[:name] and obj2['data']['ttl'] != resource[:ttl].to_i - @old_ttl = obj2['data']['ttl'] - end - if obj2['data']['fqdn'] == resource[:name] and obj2['data']['rdata'] != set_rdata(resource) - @old_rdata = obj2['data']['rdata'] - end - result = { :ensure => :present } - result[:headers] = @headers - result[:ttl] = obj2['data']['ttl'] - result[:content] = obj2['data']['rdata'].values[0].to_s - result[:type] = resource[:type] - result[:old_ttl] = @old_ttl unless @old_ttl.nil? - result[:old_rdata] = @old_rdata unless @old_rdata.nil? - result[:action] = "put" - resource.provider = new(result) - end + def self.instances(resources = nil) + if resources + resources.map do |res| + new({name:res[:name], + customername:res[:customername], + username:res[:username], + password:res[:password], + provider:'dynect', + type:res[:type], + ttl:res[:ttl], + content:Array(res[:content]), + domain:res[:domain], + ensure:res[:ensure], + require:res[:require] + }) end end end - def flush - Puppet.debug("flushing zone #{@resource[:domain]}") - if ! @property_hash.empty? && @property_hash[:ensure] != :absent - begin - Puppet.debug("Attempting to create record type #{resource[:type]} for #{resource[:name]} as #{resource[:content][0]}") if @property_hash[:action] == "post" - Puppet.debug("Attempting to edit record type #{resource[:type]} for #{resource[:name]} as #{resource[:content][0]}") if @property_hash[:action] == "put" - url = "https://api.dynect.net/REST/#{resource[:type]}Record/#{resource[:domain]}/#{resource[:name]}" - session_data = { :rdata => self.class.set_rdata(resource), :ttl => resource[:ttl] } - response = RestClient.send(@property_hash[:action].to_sym, url,session_data.to_json, @property_hash[:headers]) - obj = JSON.parse(response) - if obj['status'] == 'success' - # Publish the zone - url = "https://api2.dynect.net/REST/Zone/#{resource[:domain]}" - session_data = { "publish" => "true" } - response = RestClient.put(url,session_data.to_json,@property_hash[:headers]) - obj = JSON.parse(response) - if obj['status'] == 'success' - Puppet.info("DynECT: Created #{resource[:type]} record for #{resource[:name]} with ttl #{resource[:ttl]}") if @property_hash[:action] == "post" - if @property_hash[:old_ttl].nil? and @property_hash[:old_rdata] - Puppet.info("DynECT: Updated #{resource[:type]} record for #{resource[:name]} from #{@property_hash[:old_rdata].values[0]} to #{resource[:content][0]}") - elsif @property_hash[:old_ttl] and @property_hash[:old_rdata].nil? - Puppet.info("DynECT: Updated #{resource[:type]} record for #{resource[:name]} from ttl #{@property_hash[:old_ttl]} to #{resource[:ttl]}") - else - Puppet.info("DynECT: Updated #{resource[:type]} record for #{resource[:name]} from #{@property_hash[:old_rdata].values[0]} to #{resource[:content][0]} and ttl from #{@property_hash[:old_ttl]} to #{resource[:ttl]}") if @property_hash[:action] == "put" - end - end - end - rescue Excon::Errors::UnprocessableEntity - Puppet.info("DynECT: #{e.response.body}") - end - else - response = RestClient.delete(@property_hash[:url2],@property_hash[:headers]) - obj = JSON.parse(response) - if obj['status'] == 'success' - # Publish the zone - url = "https://api2.dynect.net/REST/Zone/#{resource[:domain]}" - session_data = { "publish" => "true" } - response = RestClient.put(url,session_data.to_json,@property_hash[:headers]) - obj = JSON.parse(response) - if obj['status'] == 'success' - Puppet.info("DynECT: destroyed #{resource[:type]} record for #{resource[:name]}") - end - end - end - @property_hash = resource.to_hash - end - - def self.set_rdata(resource) + def get_rdata(value) case resource[:type] - # Use strings over symbols because that's what dynect returns - # Makes compairison easier + # Use strings over symbols because that's what Dynect returns + # Makes comparison easier when "A" - return {"address" => "#{resource[:content][0]}"} + return {"address" => "#{value}"} when "CNAME" # Append trailing period if needed - resource[:content][0] << "." if resource[:content][0][-1,1] != "." - return {"cname" => "#{resource[:content][0]}"} + value << "." if value[-1,1] != "." + return {"cname" => "#{value}"} when "TXT" - return {"txtdata" => "#{resource[:content][0]}"} + return {"txtdata" => "#{value}"} end end - def self.post_resource_eval() - begin - url = "https://api2.dynect.net/REST/Session/" - response = RestClient.delete(url,@headers) - rescue => e - puts "DynECT error logging out - #{e}" + def flush + @customername, @username, @password = resource[:customername], resource[:username], resource[:password] + publish_zone = false + Puppet.debug("Flushing zone #{resource[:domain]}") + zone = dynect.zones.get(resource[:domain]) + content_dup = resource[:content].dup + Puppet.debug("content_dup: #{content_dup}") + case resource[:ensure] + when :present + existing = begin + zone.records.all({fqdn:resource[:name]}) + rescue Excon::Error::NotFound + [] + end + Puppet.debug("PUPPET_DNS: EXISTING: #{existing}") + + to_remove, existing = existing.partition do |r| + (!resource[:content].include?(r.rdata['address'])) && + r.type == resource[:type] && + r.name == resource[:name] + end + + Puppet.debug("EXISTING-2: #{existing}") + Puppet.debug("TO_REMOVE: #{to_remove}") + to_remove.each do |r| + Puppet.debug("Removing: #{r.inspect}") + r.destroy + publish_zone = true + end + needs_update = false + existing.each do |r| + if r.ttl != resource[:ttl] + r.ttl = resource[:ttl] + needs_update = true + end + content_dup -= [r.rdata['address']] + end + if needs_update + Puppet.debug("Updating #{resource[:name]}") + # Calling 'save' applys changes on all DNS node records and 'publish's + # the zone. so it's enough to call save on the 1st record + existing[0].save(true) # replace + publish_zone = true + end + Puppet.debug("content_dup-2: #{content_dup}") + # The remaining records in content_dup are 'new' and should be created + if !content_dup.empty? + content_dup.each do |new_ip| + zone.records.new( + name:resource[:name], + type:resource[:type], + ttl:resource[:ttl], + rdata:get_rdata(new_ip) + ).save + publish_zone = true + end + end + when :absent + to_remove = zone.records.all({fqdn:resource[:name]}).select do |r| + resource[:content].include?(r.rdata['address']) && + r.type == resource[:type] && + # In case resource[:name] resolves to multiple records(e.g + # some.domain.com would resolve to (*.)some.domain.com) + # compare the record name to apply only on relevant record(s) + r.name == resource[:name] + end + Puppet.debug("Removing: #{resource[:name]}: #{to_remove}") + to_remove.each do |r| + r.destroy + publish_zone = true + end + else + Puppet.error(" Unknow ensure: #{resource[:ensure]}, #{resource}") + raise "Unknown ensure for dns_record" + end + if publish_zone + Puppet.debug("Publishing zone: #{zone.domain}") + zone.publish end end @@ -198,8 +162,22 @@ def create end def exists? - Puppet.debug("Evaluating #{resource[:name]}") - !(@property_hash[:ensure] == :absent or @property_hash.empty?) + Puppet.debug("Checking if exists #{resource}") + @customername, @username, @password = resource[:customername], resource[:username], resource[:password] + zone = dynect.zones.get(resource[:domain]) + begin + existing = zone.records.all({fqdn:resource[:name]}).select do |r| + r.type == resource[:type] && + # In case resource[:name] resolves to multiple records(e.g + # some.domain.com would resolve to (*.)some.domain.com) + # compare the record name to apply only on relevant record(s) + r.name == resource[:name] + end + rescue Excon::Error::NotFound + return false + end + Puppet.debug("EXISTING-3: #{existing}, content: #{resource[:content]}") + (resource[:content] - existing.map do |r| r.rdata['address'] end).empty? end def destroy diff --git a/spec/spec_helper_acceptance.rb b/spec/spec_helper_acceptance.rb index 6fcd83c..838a2c2 100644 --- a/spec/spec_helper_acceptance.rb +++ b/spec/spec_helper_acceptance.rb @@ -14,7 +14,7 @@ if fact_on(host, 'osfamily') == 'Debian' install_package(master, 'ruby-dev build-essential libxml++2.6-dev') end - on master, "gem install rest-client fog --no-ri --no-rdoc" + on master, "gem install fog --no-ri --no-rdoc" on master, "gem list" end end