ezdyn 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,10 @@
1
+ module EZDyn
2
+ # baseline delay seconds
3
+ API_RETRY_DELAY_SECONDS = 1.0
4
+
5
+ # exponential backoff extra wait
6
+ API_RETRY_BACKOFF = 1.5
7
+
8
+ # max retry count
9
+ API_RETRY_MAX_ATTEMPTS = 20
10
+ end
@@ -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
@@ -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