ezdyn 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|