record_store 6.0.0 → 6.2.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f450801e1929b93095645ce0975dba493e5acc0eb3dd8582fe1e480eeccb285
4
- data.tar.gz: 9e6418b616eccdacf8b164a51627fc2ddb2d9c8eb8e233f67b03862642eeaf4a
3
+ metadata.gz: 7c401c0dafc01955edd90912d793cdd0f27bd698df86f18ed4132e3ae0a8d1bc
4
+ data.tar.gz: 261f5a6a0c768d417e247746fe95955393075c643322fb7a5c701d39edef218d
5
5
  SHA512:
6
- metadata.gz: 76bccf5594a613fc29f9052596add31a8622f5b0bbdb3cf32a1a46ee6ff91cd1312fd576b8108d86534397ac253bcc61b7fa630be0d1ffb69d5e179b424a4dad
7
- data.tar.gz: dc01e786afb99b508b1cd8b2fbb612b2a52741a9c9f12146a0b08189cf2d0d3f9366cefc05f4ba4e1fd190e55612445fc80e2484aaa91bd1f5712a51c13864e2
6
+ metadata.gz: 7160e11eec16c8ecea7fa7cf02ae7437a5093ec58f6f932a7e9e63feb85413ff5d9c28e0a90f8960ff76a94dd0bc7992e0712a0a6f40a2158062755079a16bac
7
+ data.tar.gz: 7d81f1268810c177bd785b8ab5610175d2e9b7f20db743d12996df2ab6f145c522a354a1f7e94e86e91b90c7a44b1b63bcbe1a92a1e93e0f1f5eefb13d76a659
@@ -671,7 +671,7 @@ Style/LineEndConcatenation:
671
671
  Style/MethodCallWithoutArgsParentheses:
672
672
  Enabled: true
673
673
 
674
- Style/MethodMissingSuper:
674
+ Lint/MissingSuper:
675
675
  Enabled: true
676
676
 
677
677
  Style/MissingRespondToMissing:
@@ -964,7 +964,7 @@ Lint/UselessAccessModifier:
964
964
  Lint/UselessAssignment:
965
965
  Enabled: true
966
966
 
967
- Lint/UselessComparison:
967
+ Lint/BinaryOperatorWithIdenticalOperands:
968
968
  Enabled: true
969
969
 
970
970
  Lint/UselessElseWithoutRescue:
@@ -1,5 +1,17 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 6.1.1
4
+ - Emit messages when waiting for rate-limit to elapse for DNSimple and NS1 providers, so deployment does not timeout [BUGFIX]
5
+
6
+ ## 6.1.0
7
+ - sort zone files [FEATURE]
8
+ - CLI support for specifying zones for validate_authority [FEATURE]
9
+ - retry failed lookup using another nameserver if unreachable [BUGFIX]
10
+ - ignore records other than NS in authority section [BUGFIX]
11
+
12
+ ## 6.0.1
13
+ - add API rate limiting to DNSimple provider [FEATURE]
14
+
3
15
  ## 6.0.0
4
16
  - add `--all` option for `record-store diff` to compare ignored records too [FEATURE]
5
17
 
@@ -220,12 +220,14 @@ module RecordStore
220
220
  end
221
221
  end
222
222
 
223
- desc 'validate_authority', 'Validates that authoritative nameservers match the providers'
223
+ desc 'validate_authority [ZONE ...]', 'Validates that authoritative nameservers match the providers'
224
224
  option :verbose, desc: 'Include valid zones in output', aliases: '-v', type: :boolean, default: false
225
- def validate_authority
225
+ def validate_authority(*zones)
226
226
  verbose = options.fetch('verbose')
227
227
 
228
228
  Zone.each do |name, zone|
229
+ next unless zones.empty? || zones.include?(name)
230
+
229
231
  authority = zone.fetch_authority
230
232
 
231
233
  delegation = Hash.new { |h, k| h[k] = [] }
@@ -52,6 +52,10 @@ module RecordStore
52
52
  false
53
53
  end
54
54
 
55
+ def empty_non_terminal_over_wildcard?
56
+ true
57
+ end
58
+
55
59
  def build_zone(zone_name:, config:)
56
60
  zone = Zone.new(name: zone_name)
57
61
  zone.records = retrieve_current_records(zone: zone_name)
@@ -128,6 +132,37 @@ module RecordStore
128
132
 
129
133
  dns.getresource(zone_name, Resolv::DNS::Resource::IN::SOA).mname.to_s
130
134
  end
135
+
136
+ def retry_on_connection_errors(
137
+ max_timeouts: 5,
138
+ max_conn_resets: 5,
139
+ delay: 1,
140
+ backoff_multiplier: 2,
141
+ max_backoff: 10
142
+ )
143
+ waiter = BackoffWaiter.new(
144
+ "Waiting to retry after a connection reset",
145
+ initial_delay: delay,
146
+ multiplier: backoff_multiplier,
147
+ max_delay: max_backoff,
148
+ )
149
+
150
+ loop do
151
+ begin
152
+ return yield
153
+ rescue Net::OpenTimeout
154
+ raise if max_timeouts <= 0
155
+ max_timeouts -= 1
156
+
157
+ $stderr.puts("Retrying after a connection timeout")
158
+ rescue Errno::ECONNRESET
159
+ raise if max_conn_resets <= 0
160
+ max_conn_resets -= 1
161
+
162
+ waiter.wait
163
+ end
164
+ end
165
+ end
131
166
  end
132
167
  end
133
168
  end
@@ -1,4 +1,5 @@
1
1
  require 'dnsimple'
2
+ require_relative 'dnsimple/patch_api_header'
2
3
 
3
4
  module RecordStore
4
5
  class Provider::DNSimple < Provider
@@ -11,21 +12,29 @@ module RecordStore
11
12
  true
12
13
  end
13
14
 
15
+ def empty_non_terminal_over_wildcard?
16
+ false
17
+ end
18
+
14
19
  # returns an array of Record objects that match the records which exist in the provider
15
20
  def retrieve_current_records(zone:, stdout: $stdout)
16
- session.zones.all_records(account_id, zone).data.map do |record|
17
- begin
18
- build_from_api(record, zone)
19
- rescue StandardError
20
- stdout.puts "Cannot build record: #{record}"
21
- raise
22
- end
23
- end.compact
21
+ retry_on_connection_errors do
22
+ session.zones.all_records(account_id, zone).data.map do |record|
23
+ begin
24
+ build_from_api(record, zone)
25
+ rescue StandardError
26
+ stdout.puts "Cannot build record: #{record}"
27
+ raise
28
+ end
29
+ end.compact
30
+ end
24
31
  end
25
32
 
26
33
  # Returns an array of the zones managed by provider as strings
27
34
  def zones
28
- session.zones.all_zones(account_id).data.map(&:name)
35
+ retry_on_connection_errors do
36
+ session.zones.all_zones(account_id).data.map(&:name)
37
+ end
29
38
  end
30
39
 
31
40
  private
@@ -0,0 +1,33 @@
1
+ require_relative '../provider_utils/waiter'
2
+
3
+ # Patch Dnsimple client method which retrieves headers for API rate limit dynamically
4
+ module Dnsimple
5
+ class Client
6
+ def execute(method, path, data = nil, options = {})
7
+ response = request(method, path, data, options)
8
+ rate_limit_sleep(response.headers['x-ratelimit-reset'].to_i, response.headers['x-ratelimit-remaining'].to_i)
9
+
10
+ case response.code
11
+ when 200..299
12
+ response
13
+ when 401
14
+ raise AuthenticationFailed, response['message']
15
+ when 404
16
+ raise NotFoundError, response
17
+ else
18
+ raise RequestError, response
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def rate_limit_sleep(rate_limit_reset, rate_limit_remaining)
25
+ rate_limit_reset_in = [0, rate_limit_reset - Time.now.to_i].max
26
+ rate_limit_periods = rate_limit_remaining + 1
27
+ sleep_time = rate_limit_reset_in / rate_limit_periods.to_f
28
+
29
+ rate_limit = RateLimitWaiter.new('DNSimple')
30
+ rate_limit.wait(sleep_time)
31
+ end
32
+ end
33
+ end
@@ -60,14 +60,18 @@ module RecordStore
60
60
 
61
61
  # Returns an array of the zones managed by provider as strings
62
62
  def zones
63
- client.zones.map { |zone| zone['zone'] }
63
+ retry_on_connection_errors do
64
+ client.zones.map { |zone| zone['zone'] }
65
+ end
64
66
  end
65
67
 
66
68
  private
67
69
 
68
70
  # Fetches simplified records for the provided zone
69
71
  def records_for_zone(zone)
70
- client.zone(zone)['records']
72
+ retry_on_connection_errors do
73
+ client.zone(zone)['records']
74
+ end
71
75
  end
72
76
 
73
77
  # Creates a new record to the zone. It is expected this call modifies external state.
@@ -1,11 +1,22 @@
1
1
  require 'net/http'
2
+ require_relative '../provider_utils/waiter'
2
3
 
3
4
  # Patch the method which retrieves headers for API rate limit dynamically
4
5
  module NS1::Transport
5
6
  class NetHttp
7
+ X_RATELIMIT_PERIOD = 'x-ratelimit-period'.freeze
8
+ X_RATELIMIT_REMAINING = 'x-ratelimit-remaining'.freeze
9
+
6
10
  def process_response(response)
7
- sleep(response.to_hash["x-ratelimit-period"].first.to_i /
8
- [1, response.to_hash["x-ratelimit-remaining"].first.to_i].max.to_f)
11
+ response_hash = response.to_hash
12
+
13
+ if response_hash.key?(X_RATELIMIT_PERIOD) && response_hash.key?(X_RATELIMIT_REMAINING)
14
+ sleep_time = response_hash[X_RATELIMIT_PERIOD].first.to_i /
15
+ [1, response_hash[X_RATELIMIT_REMAINING].first.to_i].max.to_f
16
+
17
+ rate_limit = RateLimitWaiter.new('NS1')
18
+ rate_limit.wait(sleep_time)
19
+ end
9
20
 
10
21
  body = JSON.parse(response.body)
11
22
  case response
@@ -0,0 +1,41 @@
1
+ class Waiter
2
+ def initialize(message = nil)
3
+ @message = message || 'Waiting'
4
+ end
5
+
6
+ attr_accessor :message
7
+
8
+ def wait(sleep_time)
9
+ while sleep_time > 0
10
+ wait_time = [10, sleep_time].min
11
+ puts "#{message} (#{sleep_time}s left)" if wait_time > 1
12
+ sleep(wait_time)
13
+ sleep_time -= wait_time
14
+ end
15
+ end
16
+ end
17
+
18
+ class RateLimitWaiter < Waiter
19
+ def initialize(provider)
20
+ super("Waiting on #{provider} rate-limit")
21
+ end
22
+ end
23
+
24
+ class BackoffWaiter < Waiter
25
+ def initialize(message, initial_delay:, multiplier:, max_delay: nil)
26
+ super(message)
27
+
28
+ @initial_delay = @current_delay = initial_delay
29
+ @multiplier = multiplier
30
+ @max_delay = max_delay
31
+ end
32
+
33
+ def reset
34
+ @current_delay = @initial_delay
35
+ end
36
+
37
+ def wait
38
+ super(@current_delay)
39
+ @current_delay = [@current_delay * @multiplier, @max_delay].compact.min
40
+ end
41
+ end
@@ -112,6 +112,10 @@ module RecordStore
112
112
  "[#{type}Record] #{fqdn} #{ttl} IN #{type} #{rdata_txt}"
113
113
  end
114
114
 
115
+ def wildcard?
116
+ fqdn.match?(/^\*\./)
117
+ end
118
+
115
119
  protected
116
120
 
117
121
  def validate_label_length
@@ -1,3 +1,3 @@
1
1
  module RecordStore
2
- VERSION = '6.0.0'.freeze
2
+ VERSION = '6.2.0'.freeze
3
3
  end
@@ -18,6 +18,7 @@ module RecordStore
18
18
  validate :validate_cname_records_dont_point_to_root
19
19
  validate :validate_same_ttl_for_records_sharing_fqdn_and_type
20
20
  validate :validate_provider_can_handle_zone_records
21
+ validate :validate_no_empty_non_terminal
21
22
  validate :validate_can_handle_alias_records
22
23
 
23
24
  class << self
@@ -129,14 +130,12 @@ module RecordStore
129
130
  )
130
131
 
131
132
  def fetch_authority(nameserver = ROOT_SERVERS.sample)
132
- authority = Resolv::DNS.open(nameserver: nameserver) do |resolv|
133
- resolv.fetch_resource(name, Resolv::DNS::Resource::IN::SOA) do |reply, name|
134
- break if reply.answer.any?
133
+ authority = fetch_soa(nameserver) do |reply, _name|
134
+ break if reply.answer.any?
135
135
 
136
- raise "No authority found (#{name})" unless reply.authority.any?
136
+ raise "No authority found (#{name})" unless reply.authority.any?
137
137
 
138
- break extract_authority(reply)
139
- end
138
+ break extract_authority(reply.authority)
140
139
  end
141
140
 
142
141
  # candidate DNS name is returned instead when NXDomain or other error
@@ -147,18 +146,39 @@ module RecordStore
147
146
 
148
147
  private
149
148
 
150
- def extract_authority(reply)
151
- authority = reply.authority.sample
149
+ def fetch_soa(nameserver)
150
+ Resolv::DNS.open(nameserver: nameserver) do |resolv|
151
+ resolv.fetch_resource(name, Resolv::DNS::Resource::IN::SOA) do |reply, name|
152
+ yield reply, name
153
+ end
154
+ end
155
+ end
156
+
157
+ def resolve_authority(authority)
158
+ nameservers = authority.map { |a| a.last.name.to_s }
159
+
160
+ begin
161
+ nameserver = nameservers.shift
162
+ fetch_authority(nameserver)
163
+ rescue Errno::EHOSTUNREACH => e
164
+ $stderr.puts "Warning: #{e} [host=#{nameserver}]"
165
+ raise if nameservers.empty?
166
+ retry
167
+ end
168
+ end
152
169
 
153
- if unrooted_name.casecmp?(authority.first.to_s)
154
- build_authority(reply.authority)
170
+ def extract_authority(authority)
171
+ if unrooted_name.casecmp?(authority.first.first.to_s)
172
+ build_authority(authority)
155
173
  else
156
- fetch_authority(authority.last.name.to_s) || build_authority(reply.authority)
174
+ resolve_authority(authority) || build_authority(authority)
157
175
  end
158
176
  end
159
177
 
160
178
  def build_authority(authority)
161
- authority.map.with_index do |(name, ttl, data), index|
179
+ ns = authority.select { |_name, _ttl, data| data.is_a?(Resolv::DNS::Resource::IN::NS) }
180
+
181
+ ns.map.with_index do |(name, ttl, data), index|
162
182
  Record::NS.new(ttl: ttl, fqdn: name.to_s, nsdname: data.name.to_s, record_id: index)
163
183
  end
164
184
  end
@@ -239,6 +259,28 @@ module RecordStore
239
259
  end
240
260
  end
241
261
 
262
+ def validate_no_empty_non_terminal
263
+ return unless config.empty_non_terminal_over_wildcard?
264
+
265
+ wildcards = records.select(&:wildcard?).map(&:fqdn).uniq
266
+ wildcards.each do |wildcard|
267
+ suffix = wildcard[1..-1]
268
+
269
+ terminal_records = records.map(&:fqdn)
270
+ .select { |record| record.match?(/^([a-zA-Z0-9-_]+\.[a-zA-Z0-9-_])#{Regexp.escape(suffix)}$/) }
271
+ next unless terminal_records.any?
272
+
273
+ intermediate_records = records.map(&:fqdn)
274
+ .select { |record| record.match?(/^([a-zA-Z0-9-_]+)#{Regexp.escape(suffix)}$/) }
275
+ terminal_records.each do |terminal_record|
276
+ non_terminal = terminal_record.partition('.').last
277
+ errors.add(:records, "found empty non-terminal #{non_terminal} "\
278
+ "(caused by existing records #{wildcard} and #{terminal_record})")\
279
+ unless intermediate_records.include?(non_terminal)
280
+ end
281
+ end
282
+ end
283
+
242
284
  def validate_can_handle_alias_records
243
285
  return unless records.any? { |record| record.is_a?(Record::ALIAS) }
244
286
  return if config.supports_alias?
@@ -24,6 +24,10 @@ module RecordStore
24
24
  end
25
25
  end
26
26
 
27
+ def empty_non_terminal_over_wildcard?
28
+ valid_providers? && providers.any? { |provider| Provider.const_get(provider).empty_non_terminal_over_wildcard? }
29
+ end
30
+
27
31
  def to_hash
28
32
  config_hash = {
29
33
  providers: providers,
@@ -10,6 +10,7 @@ module RecordStore
10
10
  def defined
11
11
  @defined ||= yaml_files
12
12
  .map { |file| load_yml_zone_definition(file) }
13
+ .sort_by(&:unrooted_name)
13
14
  .index_by(&:unrooted_name)
14
15
  end
15
16
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: record_store
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.0
4
+ version: 6.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Willem van Bergen
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2020-04-24 00:00:00.000000000 Z
12
+ date: 2020-08-18 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: thor
@@ -355,12 +355,14 @@ files:
355
355
  - lib/record_store/cli.rb
356
356
  - lib/record_store/provider.rb
357
357
  - lib/record_store/provider/dnsimple.rb
358
+ - lib/record_store/provider/dnsimple/patch_api_header.rb
358
359
  - lib/record_store/provider/dynect.rb
359
360
  - lib/record_store/provider/google_cloud_dns.rb
360
361
  - lib/record_store/provider/ns1.rb
361
362
  - lib/record_store/provider/ns1/client.rb
362
363
  - lib/record_store/provider/ns1/patch_api_header.rb
363
364
  - lib/record_store/provider/oracle_cloud_dns.rb
365
+ - lib/record_store/provider/provider_utils/waiter.rb
364
366
  - lib/record_store/record.rb
365
367
  - lib/record_store/record/a.rb
366
368
  - lib/record_store/record/aaaa.rb