Skip to content

Commit

Permalink
Merge pull request #82 from Shopify/add_ns1_provider
Browse files Browse the repository at this point in the history
Add NS1 provider
  • Loading branch information
gooallen committed Sep 30, 2019
2 parents f6461be + a1ddab5 commit fd0956c
Show file tree
Hide file tree
Showing 30 changed files with 21,640 additions and 1 deletion.
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -4,3 +4,4 @@ dev/
pkg/
Gemfile.lock
.bundle/config
.byebug_history
1 change: 1 addition & 0 deletions lib/record_store.rb
Expand Up @@ -31,6 +31,7 @@
require 'record_store/provider/dynect'
require 'record_store/provider/dnsimple'
require 'record_store/provider/google_cloud_dns'
require 'record_store/provider/ns1'
require 'record_store/cli'

module RecordStore
Expand Down
2 changes: 2 additions & 0 deletions lib/record_store/provider.rb
Expand Up @@ -19,6 +19,8 @@ def provider_for(zone_name)
'DynECT'
when /googledomains\.com\z/
'GoogleCloudDNS'
when /\.nsone\.net\z/
'NS1'
else
nil
end
Expand Down
220 changes: 220 additions & 0 deletions lib/record_store/provider/ns1.rb
@@ -0,0 +1,220 @@
require_relative 'ns1/client'

module RecordStore
class Provider::NS1 < Provider
class Error < StandardError; end

class << self
def client
Provider::NS1::Client.new(api_key: secrets['api_key'])
end

# Downloads all the records from the provider.
#
# Returns: an array of `Record` for each record in the provider's zone
def retrieve_current_records(zone:, stdout: $stdout)
full_api_records = records_for_zone(zone).map do |short_record|
client.record(
zone: zone,
fqdn: short_record["domain"],
type: short_record["type"],
must_exist: true,
)
end

full_api_records.map { |r| build_from_api(r, zone) }.flatten.compact
end

# Returns an array of the zones managed by provider as strings
def zones
client.zones.map { |zone| zone['zone'] }
end

private

# Fetches simplified records for the provided zone
def records_for_zone(zone)
client.zone(zone)["records"]
end

# Creates a new record to the zone. It is expected this call modifies external state.
#
# Arguments:
# record - a kind of `Record`
def add(record, zone)
new_answers = [{ answer: build_api_answer_from_record(record) }]

record_fqdn = record.fqdn.sub(/\.$/, '')

existing_record = client.record(
zone: zone,
fqdn: record_fqdn,
type: record.type
)

if existing_record.nil?
client.create_record(
zone: zone,
fqdn: record_fqdn,
type: record.type,
params: { answers: new_answers, ttl: record.ttl }
)
return
end

existing_answers = existing_record['answers'].map { |answer| symbolize_keys(answer) }
client.modify_record(
zone: zone,
fqdn: record_fqdn,
type: record.type,
params: { answers: existing_answers + new_answers, ttl: record.ttl }
)
end

# Deletes an existing record from the zone. It is expected this call modifies external state.
#
# Arguments:
# record - a kind of `Record`
def remove(record, zone)
record_fqdn = record.fqdn.sub(/\.$/, '')

existing_record = client.record(
zone: zone,
fqdn: record_fqdn,
type: record.type
)
return if existing_record.nil?

pruned_answers = existing_record['answers']
.map { |answer| symbolize_keys(answer) }
.reject { |answer| answer[:answer] == build_api_answer_from_record(record) }

if pruned_answers.empty?
client.delete_record(
zone: zone,
fqdn: record_fqdn,
type: record.type
)
return
end

client.modify_record(
zone: zone,
fqdn: record_fqdn,
type: record.type,
params: { answers: pruned_answers }
)
end

# Updates an existing record in the zone. It is expected this call modifies external state.
#
# Arguments:
# id - provider specific ID of record to update
# record - a kind of `Record` which the record with `id` should be updated to
def update(id, record, zone)
record_fqdn = record.fqdn.sub(/\.$/, '')

existing_record = client.record(
zone: zone,
fqdn: record_fqdn,
type: record.type,
must_exist: true,
)

# Identify the answer in this record with the matching ID, and update it
updated = false
existing_record["answers"].each do |answer|
next if answer["id"] != id
updated = true
answer["answer"] = build_api_answer_from_record(record)
end

raise(Error, "while trying to update a record, could not find answer with fqdn: #{record.fqdn}, type; #{record.type}, id: #{id}") unless updated

client.modify_record(
zone: zone,
fqdn: record_fqdn,
type: record.type,
params: { answers: existing_record["answers"], ttl: record.ttl }
)
end

def build_from_api(api_record, zone)
fqdn = Record.ensure_ends_with_dot(api_record["domain"])

record_type = api_record["type"]
return if record_type == 'SOA'

api_record["answers"].map do |api_answer|
answer = api_answer["answer"]
record = {
ttl: api_record["ttl"],
fqdn: fqdn.downcase,
record_id: api_answer["id"]
}

case record_type
when 'A', 'AAAA'
record.merge!(address: answer.first)
when 'ALIAS'
record.merge!(alias: answer.first)
when 'CAA'
flags, tag, value = answer

record.merge!(
flags: flags.to_i,
tag: tag,
value: Record.unquote(value),
)
when 'CNAME'
record.merge!(cname: answer.first)
when 'MX'

preference, exchange = answer

record.merge!(
preference: preference.to_i,
exchange: exchange,
)
when 'NS'
record.merge!(nsdname: answer.first)
when 'SPF', 'TXT'
record.merge!(txtdata: Record.unescape(answer.first).gsub(';', '\;'))
when 'SRV'
priority, weight, port, host = answer

record.merge!(
priority: priority.to_i,
weight: weight.to_i,
port: port.to_i,
target: Record.ensure_ends_with_dot(host),
)
end
Record.const_get(record_type).new(record)
end
end

def build_api_answer_from_record(record)
if record.is_a?(Record::MX)
[record.preference, record.exchange]
elsif record.is_a?(Record::TXT) or record.is_a?(Record::SPF)
[record.txtdata]
elsif record.is_a?(Record::CAA)
[record.flags, record.tag, record.value]
elsif record.is_a?(Record::SRV)
[record.priority, record.weight, record.port, record.target]
else
[record.rdata_txt]
end
end

def symbolize_keys(hash)
hash.map{ |key, value| [key.to_sym, value] }.to_h
end

def secrets
super.fetch('ns1')
end
end
end
end
47 changes: 47 additions & 0 deletions lib/record_store/provider/ns1/client.rb
@@ -0,0 +1,47 @@
require 'ns1'

module RecordStore
class Provider::NS1 < Provider
class Error < StandardError; end

class Client < ::NS1::Client
def initialize(api_key:)
super(api_key)
end

def zones
super
end

def zone(name)
super(name)
end

def record(zone:, fqdn:, type:, must_exist: false)
result = super(zone, fqdn, type)
raise(Error, result.to_s) if must_exist && result.is_a?(NS1::Response::Error)
return nil if result.is_a?(NS1::Response::Error)
result
end

def create_record(zone:, fqdn:, type:, params:)
result = super(zone, fqdn, type, params)
raise(Error, result.to_s) if result.is_a? NS1::Response::Error
nil
end

def modify_record(zone:, fqdn:, type:, params:)
result = super(zone, fqdn, type, params)
raise(Error, result.to_s) if result.is_a? NS1::Response::Error
nil
end

def delete_record(zone:, fqdn:, type:)
result = super(zone, fqdn, type)
raise(Error, result.to_s) if result.is_a? NS1::Response::Error
nil
end
end
end

end
2 changes: 1 addition & 1 deletion lib/record_store/version.rb
@@ -1,3 +1,3 @@
module RecordStore
VERSION = '5.4.3'.freeze
VERSION = '5.5.3'.freeze
end
3 changes: 3 additions & 0 deletions record_store.gemspec
Expand Up @@ -31,7 +31,10 @@ Gem::Specification.new do |spec|
spec.add_runtime_dependency 'dnsimple', '~> 4.4.0'
spec.add_runtime_dependency 'google-cloud-dns'
spec.add_runtime_dependency 'ruby-limiter', '~> 1.0', '>= 1.0.1'
spec.add_runtime_dependency 'ns1'


spec.add_development_dependency 'byebug'
spec.add_development_dependency 'rake'
spec.add_development_dependency 'bundler'
spec.add_development_dependency 'mocha'
Expand Down
3 changes: 3 additions & 0 deletions template/secrets.json
Expand Up @@ -9,6 +9,9 @@
"account_id": "dnsimple_account_id",
"api_token": "dnsimple_api_token"
},
"ns1": {
"api_key": "ns1_api_key"
},
"google_cloud_dns": {
"type": "type",
"project_id": "project_id",
Expand Down
3 changes: 3 additions & 0 deletions test/dummy/secrets.json
Expand Up @@ -9,6 +9,9 @@
"account_id": "1234",
"api_token": "dnsimple_api_token"
},
"ns1": {
"api_key": "ns1_api_key"
},
"google_cloud_dns": {
"type": "service_account",
"project_id": "project_id",
Expand Down

0 comments on commit fd0956c

Please sign in to comment.