-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #82 from Shopify/add_ns1_provider
Add NS1 provider
- Loading branch information
Showing
30 changed files
with
21,640 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,3 +4,4 @@ dev/ | |
pkg/ | ||
Gemfile.lock | ||
.bundle/config | ||
.byebug_history |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,3 @@ | ||
module RecordStore | ||
VERSION = '5.4.3'.freeze | ||
VERSION = '5.5.3'.freeze | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.