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.
@@ -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