ezdyn 0.2.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +30 -0
- data/README.md +139 -0
- data/bin/ezdyn +226 -0
- data/ezdyn.gemspec +24 -0
- data/lib/ezdyn.rb +11 -0
- data/lib/ezdyn/changes.rb +111 -0
- data/lib/ezdyn/client.rb +203 -0
- data/lib/ezdyn/consts.rb +10 -0
- data/lib/ezdyn/crud.rb +199 -0
- data/lib/ezdyn/log.rb +47 -0
- data/lib/ezdyn/record.rb +142 -0
- data/lib/ezdyn/record_type.rb +78 -0
- data/lib/ezdyn/response.rb +82 -0
- data/lib/ezdyn/version.rb +4 -0
- data/lib/ezdyn/zone.rb +113 -0
- metadata +73 -0
data/lib/ezdyn/client.rb
ADDED
@@ -0,0 +1,203 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
|
3
|
+
module EZDyn
|
4
|
+
# The main class for Dyn REST API interaction.
|
5
|
+
#
|
6
|
+
# For more information about the API, see
|
7
|
+
# {https://help.dyn.com/dns-api-knowledge-base/ the official documentation}.
|
8
|
+
#
|
9
|
+
class Client
|
10
|
+
# Initializes a new Dyn REST API client.
|
11
|
+
#
|
12
|
+
# @param customer_name [String] API customer name.
|
13
|
+
# @param username [String] API username.
|
14
|
+
# @param password [String] API password.
|
15
|
+
# @return [Client] Dyn REST API client.
|
16
|
+
def initialize(customer_name: ENV['DYN_CUSTOMER_NAME'], username: ENV['DYN_USERNAME'], password: ENV['DYN_PASSWORD'])
|
17
|
+
@customer_name = customer_name
|
18
|
+
@username = username
|
19
|
+
@password = password
|
20
|
+
|
21
|
+
if @customer_name.nil? or @username.nil? or @password.nil?
|
22
|
+
EZDyn.info { "Some credentials are missing" }
|
23
|
+
raise "Missing credentials"
|
24
|
+
end
|
25
|
+
|
26
|
+
@base_url = URI('https://api2.dynect.net/')
|
27
|
+
@headers = {
|
28
|
+
'Content-Type' => 'application/json',
|
29
|
+
}
|
30
|
+
|
31
|
+
@logged_in = false
|
32
|
+
end
|
33
|
+
|
34
|
+
# @private
|
35
|
+
def build_url(path)
|
36
|
+
path = path.to_s
|
37
|
+
path = "/#{path}" unless path.start_with?('/')
|
38
|
+
path = "/REST#{path}" unless path.start_with?('/REST')
|
39
|
+
@base_url.merge(path)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Performs an actual REST call.
|
43
|
+
#
|
44
|
+
# @param method [String] HTTP method to use, eg "GET", "POST", "DELETE", "PUT".
|
45
|
+
# @param uri [String] Relative API URI to call.
|
46
|
+
# @param payload [Hash] JSON data payload to send with request.
|
47
|
+
# @return [Response] Response object.
|
48
|
+
def call_api(method: "get", uri:, payload: {}, max_attempts: EZDyn::API_RETRY_MAX_ATTEMPTS)
|
49
|
+
EZDyn.debug { "API CALL: #{method.to_s.upcase} #{uri} #{ ( payload || {} ).to_json }" }
|
50
|
+
self.login if not self.logged_in? and uri != "Session"
|
51
|
+
|
52
|
+
payload_str = payload.to_json
|
53
|
+
if payload == {}
|
54
|
+
payload_str = nil
|
55
|
+
end
|
56
|
+
|
57
|
+
response = nil
|
58
|
+
begin
|
59
|
+
EZDyn.debug { "About to make REST request to #{method.to_s.upcase} #{uri}" }
|
60
|
+
|
61
|
+
request_url = build_url(uri)
|
62
|
+
|
63
|
+
request = Net::HTTP.const_get(method.capitalize).new(request_url)
|
64
|
+
@headers.each do |name, value|
|
65
|
+
request[name] = value
|
66
|
+
end
|
67
|
+
request.body = payload_str if payload_str
|
68
|
+
|
69
|
+
http = Net::HTTP.new(request_url.host, request_url.port)
|
70
|
+
http.use_ssl = true if request_url.scheme == 'https'
|
71
|
+
response = Response.new(http.start { |http| http.request(request) })
|
72
|
+
|
73
|
+
if response.delayed?
|
74
|
+
wait = EZDyn::API_RETRY_DELAY_SECONDS
|
75
|
+
max_attempts.times do |retry_count|
|
76
|
+
EZDyn.debug { "Async API call response retrieval, attempt #{retry_count + 1}" }
|
77
|
+
EZDyn.debug { "Waiting for #{wait} seconds" }
|
78
|
+
sleep wait
|
79
|
+
|
80
|
+
response = self.call_api(uri: "/REST/Job/#{response.job_id}")
|
81
|
+
break if response.success?
|
82
|
+
|
83
|
+
EZDyn.debug { "Async response status: #{response.status}" }
|
84
|
+
wait += (retry_count * EZDyn::API_RETRY_BACKOFF)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
EZDyn.debug { "Call was successful" }
|
89
|
+
rescue => e
|
90
|
+
EZDyn.info { "REST request to #{uri} threw an exception: #{e}" }
|
91
|
+
raise "Got an exception: #{e}"
|
92
|
+
end
|
93
|
+
|
94
|
+
response
|
95
|
+
end
|
96
|
+
|
97
|
+
# Signals whether the client has successfully logged in.
|
98
|
+
#
|
99
|
+
# @return [Boolean] True if the client is logged in.
|
100
|
+
def logged_in?
|
101
|
+
@logged_in
|
102
|
+
end
|
103
|
+
|
104
|
+
# Begin a Dyn REST API session. This method will be called implicitly when required.
|
105
|
+
def login
|
106
|
+
EZDyn.debug { "Logging in..." }
|
107
|
+
response = call_api(
|
108
|
+
method: "post",
|
109
|
+
uri: "Session",
|
110
|
+
payload: {
|
111
|
+
customer_name: @customer_name,
|
112
|
+
user_name: @username,
|
113
|
+
password: @password,
|
114
|
+
}
|
115
|
+
)
|
116
|
+
|
117
|
+
EZDyn.debug { "Response status: #{response.status}" }
|
118
|
+
|
119
|
+
if response.success?
|
120
|
+
@headers['Auth-Token'] = response.data["token"]
|
121
|
+
@logged_in = true
|
122
|
+
else
|
123
|
+
raise "Login failed"
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# End the current Dyn REST API session.
|
128
|
+
def logout
|
129
|
+
call_api(
|
130
|
+
method: "delete",
|
131
|
+
uri: "Session"
|
132
|
+
)
|
133
|
+
|
134
|
+
@headers.delete('Auth-Token')
|
135
|
+
@logged_in = false
|
136
|
+
end
|
137
|
+
|
138
|
+
# @private
|
139
|
+
def build_uri(type:, fqdn:, id: nil)
|
140
|
+
EZDyn.debug { "Client.build_uri( type: #{type}, fqdn: #{fqdn}, id: #{id} )" }
|
141
|
+
"#{RecordType.find(type).uri_name}/#{self.guess_zone(fqdn: fqdn)}/#{fqdn}/#{id}"
|
142
|
+
end
|
143
|
+
|
144
|
+
# @private
|
145
|
+
def fetch_uri_data(uri:)
|
146
|
+
EZDyn.debug { "Client.fetch_uri_data( uri: #{uri} )" }
|
147
|
+
response = call_api(uri: uri)
|
148
|
+
if response.success?
|
149
|
+
EZDyn.debug { "fetch_uri_data success!" }
|
150
|
+
return response.data
|
151
|
+
else
|
152
|
+
EZDyn.debug { "fetch_uri_data failure!" }
|
153
|
+
return []
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# Fetches a record if you know the type, FQDN, and ID.
|
158
|
+
#
|
159
|
+
# @param type [EZDyn::RecordType, String, Symbol] The record type to be fetched.
|
160
|
+
# @param fqdn [String] FQDN of the record to fetch.
|
161
|
+
# @param id [String] Dyn API record ID of the record to fetch.
|
162
|
+
# @return [EZDyn::Record] Newly created, synced Record object.
|
163
|
+
def fetch_record(type:, fqdn:, id:)
|
164
|
+
EZDyn.debug { "Client.fetch_record( type: #{type}, fqdn: #{fqdn}, id: #{id} )" }
|
165
|
+
data = self.fetch_uri_data(uri: self.build_uri(type: type, fqdn: fqdn, id: id))
|
166
|
+
if data == []
|
167
|
+
nil
|
168
|
+
else
|
169
|
+
Record.new(client: self, raw: data)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
# Fetches all records for a particular FQDN (and record type).
|
174
|
+
#
|
175
|
+
# @param type [EZDyn::RecordType, String, Symbol] Desired record type. Use
|
176
|
+
# `:any` to fetch all records.
|
177
|
+
# @param fqdn [String] FQDN of the records to fetch.
|
178
|
+
# @return [Array<EZDyn::Record>] An array of synced Record objects.
|
179
|
+
def records_for(type: :any, fqdn:)
|
180
|
+
EZDyn.debug { "Client.records_for( type: #{type}, fqdn: #{fqdn} )" }
|
181
|
+
self.fetch_uri_data(uri: self.build_uri(type: type, fqdn: fqdn)).
|
182
|
+
collect { |uri| Record.new(client: self, uri: uri) }
|
183
|
+
end
|
184
|
+
|
185
|
+
# Signals if a particular record exists.
|
186
|
+
#
|
187
|
+
# @param type [EZDyn::RecordType, String, Symbol] RecordType of record to
|
188
|
+
# check for. `:any` will match any record type.
|
189
|
+
# @param fqdn [String] FQDN of records to check for. Required.
|
190
|
+
# @param id [String] API record ID of the record to check for. Optional.
|
191
|
+
# @return [Boolean] Returns true if any such record exists.
|
192
|
+
def exists?(type: :any, fqdn:, id: nil)
|
193
|
+
EZDyn.debug { "Client.exists?( type: #{type}, fqdn: #{fqdn}, id: #{id} )" }
|
194
|
+
if not id.nil? and type != :any
|
195
|
+
EZDyn.debug { "Fetching a single record" }
|
196
|
+
self.fetch_record(type: type, fqdn: fqdn, id: id) != []
|
197
|
+
else
|
198
|
+
EZDyn.debug { "Fetching records_for #{type} #{fqdn}" }
|
199
|
+
self.records_for(type: type, fqdn: fqdn).count > 0
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
data/lib/ezdyn/consts.rb
ADDED
data/lib/ezdyn/crud.rb
ADDED
@@ -0,0 +1,199 @@
|
|
1
|
+
module EZDyn
|
2
|
+
class Client
|
3
|
+
# Calls the Dyn API to create a new record.
|
4
|
+
#
|
5
|
+
# @note As a side effect upon success, this method creates a [CreateChange]
|
6
|
+
# object in the `pending_changes` array.
|
7
|
+
#
|
8
|
+
# @raise [RuntimeError] if such a record already exists.
|
9
|
+
# @raise [RuntimeError] if the record could not be created.
|
10
|
+
# @param type [RecordType, String, Symbol] Type of record to create.
|
11
|
+
# @param fqdn [String] FQDN of the record to create.
|
12
|
+
# @param value [String, Hash] Value to submit for the record data.
|
13
|
+
# @param ttl [String, Integer] TTL value (optional).
|
14
|
+
# @return [Record] A Record object filled with the values returned by the API.
|
15
|
+
def create(type:, fqdn:, value:, ttl: nil)
|
16
|
+
EZDyn.debug { "Client.create( type: #{type}, fqdn: #{fqdn}, value: #{value}, ttl: #{ttl} )" }
|
17
|
+
if self.exists?(type: type, fqdn: fqdn)
|
18
|
+
raise "EZDyn does not yet support multiple records of the same type on a single node."
|
19
|
+
end
|
20
|
+
|
21
|
+
ttl = ( ttl || Record::DefaultTTL ).to_i
|
22
|
+
|
23
|
+
response = self.call_api(
|
24
|
+
method: "post",
|
25
|
+
uri: self.build_uri(type: type, fqdn: fqdn),
|
26
|
+
payload: { rdata: { RecordType.find(type).value_key => value }, ttl: ttl }
|
27
|
+
)
|
28
|
+
|
29
|
+
if not response.success?
|
30
|
+
raise "Failed to create record: #{response.simple_message}"
|
31
|
+
end
|
32
|
+
|
33
|
+
record = Record.new(client: self, raw: response.data)
|
34
|
+
self.add_pending_change(CreateChange.new(record: record))
|
35
|
+
|
36
|
+
return record
|
37
|
+
end
|
38
|
+
|
39
|
+
# Calls the Dyn API to update or create a record. Could also be called
|
40
|
+
# `upsert`.
|
41
|
+
#
|
42
|
+
# @note As a side effect upon success, this method creates an [UpdateChange]
|
43
|
+
# or a [CreateChange] object in the `pending_changes` array.
|
44
|
+
#
|
45
|
+
# @raise [RuntimeError] if multiple records exist and no specific Record object was passed.
|
46
|
+
# @raise [RuntimeError] if the record could not be created or updated.
|
47
|
+
# @param record [Record] A Record object for the record to be updated.
|
48
|
+
# Either this parameter or the `type` and `fqdn` parameters are
|
49
|
+
# required.
|
50
|
+
# @param type [RecordType, String, Symbol] Type of record to update/create (not required if `record` is provided).
|
51
|
+
# @param fqdn [String] FQDN of the record to update/create (not requried if `record` is provided).
|
52
|
+
# @param value [String, Hash] New value to submit for the record data (optional if updating TTL and record already exists).
|
53
|
+
# @param ttl [String, Integer] New TTL value (optional).
|
54
|
+
# @return [Record] A Record object filled with the values returned by the API.
|
55
|
+
def update(record: nil, type: nil, fqdn: nil, value: nil, ttl: nil)
|
56
|
+
EZDyn.debug { "Client.update( record: #{record.nil? ? nil : "Record{#{record.record_id}}"}, type: #{type}, fqdn: #{fqdn}, value: #{value}, ttl: #{ttl} )" }
|
57
|
+
|
58
|
+
if record.nil?
|
59
|
+
if type.nil? or fqdn.nil?
|
60
|
+
raise "Cannot update a record without a Record object or both record type and FQDN"
|
61
|
+
end
|
62
|
+
records = self.records_for(type: type, fqdn: fqdn)
|
63
|
+
record =
|
64
|
+
if records.count == 0
|
65
|
+
# do nothing, and let it be created
|
66
|
+
nil
|
67
|
+
elsif records.count == 1
|
68
|
+
records.first
|
69
|
+
else
|
70
|
+
raise "EZDyn does not (yet) support updating over multiple records"
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
response = nil
|
75
|
+
if record.nil?
|
76
|
+
if not fqdn.nil? and not type.nil? and not value.nil?
|
77
|
+
return self.create(type: type, fqdn: fqdn, value: value, ttl: ttl)
|
78
|
+
else
|
79
|
+
raise "Record doesn't exist, and insufficient information to create it was given"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
record.sync!
|
84
|
+
response = self.call_api(
|
85
|
+
method: "put",
|
86
|
+
uri: record.uri,
|
87
|
+
payload: {
|
88
|
+
rdata: {
|
89
|
+
record.type.value_key => ( value || record.value ),
|
90
|
+
},
|
91
|
+
ttl: ( ttl || record.ttl ),
|
92
|
+
}
|
93
|
+
)
|
94
|
+
|
95
|
+
if not response.success?
|
96
|
+
raise "Could not update: #{response.simple_message}"
|
97
|
+
end
|
98
|
+
|
99
|
+
new_record = Record.new(client: self, raw: response.data)
|
100
|
+
self.add_pending_change(UpdateChange.new(record: record, new_record: new_record))
|
101
|
+
|
102
|
+
return new_record
|
103
|
+
end
|
104
|
+
|
105
|
+
# Delete a record.
|
106
|
+
#
|
107
|
+
# @note As a side effect upon success, this method creates a [DeleteChange]
|
108
|
+
# object in the `pending_changes` array.
|
109
|
+
#
|
110
|
+
# @raise [RuntimeError] if record does not exist.
|
111
|
+
# @raise [RuntimeError] if record cannot be deleted.
|
112
|
+
# @param record [Record] The Record object of the record to be deleted.
|
113
|
+
def delete(record:)
|
114
|
+
EZDyn.debug { "Client{}.delete( record: Record{#{record.record_id}} )" }
|
115
|
+
if not record.sync!.exists?
|
116
|
+
raise "Nothing to delete"
|
117
|
+
end
|
118
|
+
|
119
|
+
response = self.call_api(
|
120
|
+
method: "delete",
|
121
|
+
uri: record.uri
|
122
|
+
)
|
123
|
+
|
124
|
+
if not response.success?
|
125
|
+
raise "Could not delete: #{response.simple_message}"
|
126
|
+
end
|
127
|
+
|
128
|
+
self.add_pending_change(DeleteChange.new(record: record))
|
129
|
+
end
|
130
|
+
|
131
|
+
# Deletes all records of a specified type and FQDN. Specify type `:any`
|
132
|
+
# (or don't specify `type` at all) to delete all records for a FQDN.
|
133
|
+
#
|
134
|
+
# @note As a side effect upon success, this method creates [DeleteChange]
|
135
|
+
# objects in the `pending_changes` array for each record deleted.
|
136
|
+
#
|
137
|
+
# @param type [RecordType,String,Symbol] The type of record(s) to delete.
|
138
|
+
# Defaults to `:any`.
|
139
|
+
# @param fqdn [String] The FQDN of the record(s) to delete.
|
140
|
+
def delete_all(type: :any, fqdn:)
|
141
|
+
EZDyn.debug { "Client{}.delete_all( type: #{type}, fqdn: #{fqdn} )" }
|
142
|
+
self.records_for(type: type, fqdn: fqdn).each do |record|
|
143
|
+
record.delete!
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Rolls back any pending changes to a zone or to all zones.
|
148
|
+
#
|
149
|
+
# @param zone [String] The zone name to roll back. By default all pending
|
150
|
+
# changes from any zone will be rolled back.
|
151
|
+
def rollback(zone: nil)
|
152
|
+
EZDyn.debug { "Client{}.rollback( zone: #{zone} )" }
|
153
|
+
|
154
|
+
zones = zone.nil? ? self.pending_change_zones : [Zone.new(client: self, name: zone)]
|
155
|
+
|
156
|
+
zones.each do |zone|
|
157
|
+
EZDyn.debug { " - rolling back Zone{#{zone.name}}" }
|
158
|
+
response = self.call_api(method: "delete", uri: "ZoneChanges/#{zone}")
|
159
|
+
if response.success?
|
160
|
+
self.clear_pending_changes(zone: zone)
|
161
|
+
else
|
162
|
+
EZDyn.debug { " - failed to roll back Zone{#{zone.name}}: #{response.simple_message}" }
|
163
|
+
raise "Failed to roll back zone #{zone.name}"
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# Commits any pending changes to a zone or to all zones with an optional update message.
|
169
|
+
#
|
170
|
+
# @raise [RuntimeError] if any commit was unsuccessful.
|
171
|
+
# @param zone [String] The zone name to commit. By default all pending
|
172
|
+
# changes from any zone will be committed.
|
173
|
+
# @param message [String] If supplied, this message will be used for the
|
174
|
+
# zone update notes field.
|
175
|
+
def commit(zone: nil, message: nil)
|
176
|
+
EZDyn.debug { "Client{}.commit( zone: #{zone} )" }
|
177
|
+
payload = { publish: true }
|
178
|
+
payload[:notes] = message if not message.nil?
|
179
|
+
|
180
|
+
zones = zone.nil? ? self.pending_change_zones : [Zone.find(zone)]
|
181
|
+
|
182
|
+
zones.each do |zone|
|
183
|
+
EZDyn.debug { " - committing Zone{#{zone.name}}" }
|
184
|
+
response = self.call_api(
|
185
|
+
method: "put",
|
186
|
+
uri: "Zone/#{zone}",
|
187
|
+
payload: payload
|
188
|
+
)
|
189
|
+
|
190
|
+
if response.success?
|
191
|
+
self.clear_pending_changes(zone: zone)
|
192
|
+
else
|
193
|
+
EZDyn.debug { " - failed to commit Zone{#{zone.name}}: #{response.simple_message}" }
|
194
|
+
raise "Could not commit zone #{zone.name}: #{reponse.simple_message}"
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
data/lib/ezdyn/log.rb
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'logger'
|
2
|
+
|
3
|
+
# Base module for the ezdyn gem. Offers logging functionality via module
|
4
|
+
# methods #debug and #info.
|
5
|
+
#
|
6
|
+
# Set the environment variable `EZDYN_DEBUG` to any value to enable debug
|
7
|
+
# logging to `stderr`.
|
8
|
+
#
|
9
|
+
module EZDyn
|
10
|
+
# @private
|
11
|
+
class EZDynLog
|
12
|
+
attr_reader :logger
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@logger = Logger.new(STDERR)
|
16
|
+
@logger.level = Logger::DEBUG
|
17
|
+
@logger.datetime_format = '%Y-%m-%d %H:%M:%S'
|
18
|
+
@logger.formatter =
|
19
|
+
proc do |sev, dt, prog, msg|
|
20
|
+
"#{dt} #{sev}: #{msg}\n"
|
21
|
+
end
|
22
|
+
@logger.debug { "Logger initialized" }
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# @private
|
27
|
+
@@logger =
|
28
|
+
if ENV["EZDYN_DEBUG"]
|
29
|
+
EZDyn::EZDynLog.new
|
30
|
+
end
|
31
|
+
|
32
|
+
# Logs a debug message to the default logger containing the value yielded
|
33
|
+
# by the given block.
|
34
|
+
def self.debug &block
|
35
|
+
if block_given? and not @@logger.nil?
|
36
|
+
@@logger.logger.debug { yield }
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Logs an info-level message to the default logger containing the value yielded
|
41
|
+
# by the given block.
|
42
|
+
def self.info &block
|
43
|
+
if block_given? and not @@logger.nil?
|
44
|
+
@@logger.logger.info { yield }
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|