record_store 6.1.1 → 6.3.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f7a5da9483eaf8ce4eb82e8d974fefe82f7f481cde945a45ec6ae204b02524dc
4
- data.tar.gz: 51e681688b6cc80c851f801ad00bb07ccf473ea2fb0b1c8a8e722d3cbc2ec196
3
+ metadata.gz: f6875c27f0cf86510b733a58f668d44560eeb40e7c8b6c9438183fee175bc514
4
+ data.tar.gz: 3599cc9903a5afe46d544f8aeef349961fdc2d95011973325bbdcc75972c56c5
5
5
  SHA512:
6
- metadata.gz: 502acb53fcef150d5cb5442414c47552fc199efe66012e85d30745ae25c8af326064283c855ba12f4cead5b6f7b09f5a97c0b3bd6a4de1a010bba15664954335
7
- data.tar.gz: 491ee9ff80ccb35ceb7ffbe70776948cd459467df8c213917c08ac6830e249585dbd993849a44e413bd6fc8e38589601eab27601cf3061c9b51abd09a94898a0
6
+ metadata.gz: 9a6def9342919457cc71fdda4691c92310450dfc28a476eee6d353b73798b8d1b612b4775982704b0c44b2e657149a02f0e96330796b03d591619ad5930509ae
7
+ data.tar.gz: 42183ae0ecd31e27a2d40977ecdda6cb51c0368ff24ac3a2de4411ab016809c5551ee3faeb50eb53503be059ece4bc41889e6c73d3e1f0ab53e7ab9290563628
@@ -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,7 +1,7 @@
1
1
  cache: bundler
2
2
  language: ruby
3
3
  rvm:
4
- - 2.4.3
4
+ - 2.6.4
5
5
 
6
6
  before_install:
7
7
  - gem update --system
@@ -1,5 +1,20 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 6.3.1
4
+ - Improve resiliency in the face of temporary provider outages [BUGFIX]
5
+
6
+ ## 6.3.0
7
+ - Support for configurable number of threads via environment variable [FEATURE]
8
+
9
+ ## 6.2.1
10
+ - Improved error reporting after timeouts [FEATURE]
11
+
12
+ ## 6.2.0
13
+ - Add validation for non-terminal conflict with wildcard [FEATURE]
14
+
15
+ ## 6.1.2
16
+ - Retry on connection errors [FEATURE]
17
+
3
18
  ## 6.1.1
4
19
  - Emit messages when waiting for rate-limit to elapse for DNSimple and NS1 providers, so deployment does not timeout [BUGFIX]
5
20
 
data/README.md CHANGED
@@ -112,6 +112,10 @@ Changesets are how Record Store knows what updates to make. A `Changeset` is gen
112
112
 
113
113
  When running `bin/record-store apply`, a `Changeset` is generated by comparing the current records in a zone's YAML file with the records the provider defines. A zone's YAML file is always considered the primary source of truth.
114
114
 
115
+ ### Parallelism
116
+
117
+ Record store attempts to parallelize some of the bulk zone fetching operations. It does so by spawning multiple threads (default: 10). This value can be configured by setting the RECORD_STORE_MAX_THREADS environment variable to a positive integer value.
118
+
115
119
  ----
116
120
 
117
121
  # Development
data/dev.yml CHANGED
@@ -2,7 +2,7 @@
2
2
  name: recordstore
3
3
 
4
4
  up:
5
- - ruby: 2.5.0
5
+ - ruby: 2.6.4
6
6
  - bundler
7
7
 
8
8
  commands:
@@ -2,6 +2,9 @@ require 'resolv'
2
2
 
3
3
  module RecordStore
4
4
  class Provider
5
+ class Error < StandardError; end
6
+ class UnparseableBodyError < Error; end
7
+
5
8
  class << self
6
9
  def provider_for(object)
7
10
  ns_server =
@@ -52,6 +55,10 @@ module RecordStore
52
55
  false
53
56
  end
54
57
 
58
+ def empty_non_terminal_over_wildcard?
59
+ true
60
+ end
61
+
55
62
  def build_zone(zone_name:, config:)
56
63
  zone = Zone.new(name: zone_name)
57
64
  zone.records = retrieve_current_records(zone: zone_name)
@@ -128,6 +135,43 @@ module RecordStore
128
135
 
129
136
  dns.getresource(zone_name, Resolv::DNS::Resource::IN::SOA).mname.to_s
130
137
  end
138
+
139
+ def retry_on_connection_errors(
140
+ max_timeouts: 5,
141
+ max_conn_resets: 5,
142
+ max_retries: 5,
143
+ delay: 1,
144
+ backoff_multiplier: 2,
145
+ max_backoff: 10
146
+ )
147
+ waiter = BackoffWaiter.new(
148
+ 'Waiting to retry after a connection reset',
149
+ initial_delay: delay,
150
+ multiplier: backoff_multiplier,
151
+ max_delay: max_backoff,
152
+ )
153
+
154
+ loop do
155
+ begin
156
+ return yield
157
+ rescue UnparseableBodyError
158
+ raise if max_retries <= 0
159
+ max_retries -= 1
160
+
161
+ waiter.wait(message: 'Waiting to retry after receiving an unparseable response')
162
+ rescue Net::OpenTimeout, Errno::ETIMEDOUT
163
+ raise if max_timeouts <= 0
164
+ max_timeouts -= 1
165
+
166
+ $stderr.puts('Retrying after a connection timeout')
167
+ rescue Errno::ECONNRESET
168
+ raise if max_conn_resets <= 0
169
+ max_conn_resets -= 1
170
+
171
+ waiter.wait
172
+ end
173
+ end
174
+ end
131
175
  end
132
176
  end
133
177
  end
@@ -12,21 +12,29 @@ module RecordStore
12
12
  true
13
13
  end
14
14
 
15
+ def empty_non_terminal_over_wildcard?
16
+ false
17
+ end
18
+
15
19
  # returns an array of Record objects that match the records which exist in the provider
16
20
  def retrieve_current_records(zone:, stdout: $stdout)
17
- session.zones.all_records(account_id, zone).data.map do |record|
18
- begin
19
- build_from_api(record, zone)
20
- rescue StandardError
21
- stdout.puts "Cannot build record: #{record}"
22
- raise
23
- end
24
- 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
25
31
  end
26
32
 
27
33
  # Returns an array of the zones managed by provider as strings
28
34
  def zones
29
- 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
30
38
  end
31
39
 
32
40
  private
@@ -1,4 +1,4 @@
1
- require_relative '../provider_utils/rate_limit'
1
+ require_relative '../provider_utils/waiter'
2
2
 
3
3
  # Patch Dnsimple client method which retrieves headers for API rate limit dynamically
4
4
  module Dnsimple
@@ -26,8 +26,8 @@ module Dnsimple
26
26
  rate_limit_periods = rate_limit_remaining + 1
27
27
  sleep_time = rate_limit_reset_in / rate_limit_periods.to_f
28
28
 
29
- rate_limit = RateLimit.new('DNSimple')
30
- rate_limit.sleep_for(sleep_time)
29
+ rate_limit = RateLimitWaiter.new('DNSimple')
30
+ rate_limit.wait(sleep_time)
31
31
  end
32
32
  end
33
33
  end
@@ -3,8 +3,6 @@ require_relative 'ns1/patch_api_header'
3
3
 
4
4
  module RecordStore
5
5
  class Provider::NS1 < Provider
6
- class Error < StandardError; end
7
-
8
6
  class ApiAnswer
9
7
  class << self
10
8
  def from_full_api_answer(type:, record_id:, answer:)
@@ -60,14 +58,18 @@ module RecordStore
60
58
 
61
59
  # Returns an array of the zones managed by provider as strings
62
60
  def zones
63
- client.zones.map { |zone| zone['zone'] }
61
+ retry_on_connection_errors do
62
+ client.zones.map { |zone| zone['zone'] }
63
+ end
64
64
  end
65
65
 
66
66
  private
67
67
 
68
68
  # Fetches simplified records for the provided zone
69
69
  def records_for_zone(zone)
70
- client.zone(zone)['records']
70
+ retry_on_connection_errors do
71
+ client.zone(zone)['records']
72
+ end
71
73
  end
72
74
 
73
75
  # Creates a new record to the zone. It is expected this call modifies external state.
@@ -176,7 +178,7 @@ module RecordStore
176
178
  unless updated
177
179
  error = +'while trying to update a record, could not find answer with fqdn: '
178
180
  error << "#{record.fqdn}, type; #{record.type}, id: #{id}"
179
- raise Error, error
181
+ raise RecordStore::Provider::Error, error
180
182
  end
181
183
 
182
184
  client.modify_record(
@@ -1,46 +1,59 @@
1
+ require 'net/http'
1
2
  require 'ns1'
2
3
 
3
4
  module RecordStore
4
5
  class Provider::NS1 < Provider
5
- class Error < StandardError; end
6
-
7
6
  class Client < ::NS1::Client
8
7
  def initialize(api_key:)
9
8
  super(api_key)
10
9
  end
11
10
 
12
11
  def zones
13
- super
12
+ zones = super
13
+ raise_if_error!(zones)
14
+ zones
14
15
  end
15
16
 
16
17
  def zone(name)
17
- super(name)
18
+ zone = super(name)
19
+ raise_if_error!(zone)
20
+ zone
18
21
  end
19
22
 
20
23
  def record(zone:, fqdn:, type:, must_exist: false)
21
24
  result = super(zone, fqdn, type)
22
- raise(Error, result.to_s) if must_exist && result.is_a?(NS1::Response::Error)
25
+ raise_if_error!(result) if must_exist
23
26
  return nil if result.is_a?(NS1::Response::Error)
24
27
  result
25
28
  end
26
29
 
27
30
  def create_record(zone:, fqdn:, type:, params:)
28
31
  result = super(zone, fqdn, type, params)
29
- raise(Error, result.to_s) if result.is_a?(NS1::Response::Error)
32
+ raise_if_error!(result)
30
33
  nil
31
34
  end
32
35
 
33
36
  def modify_record(zone:, fqdn:, type:, params:)
34
37
  result = super(zone, fqdn, type, params)
35
- raise(Error, result.to_s) if result.is_a?(NS1::Response::Error)
38
+ raise_if_error!(result)
36
39
  nil
37
40
  end
38
41
 
39
42
  def delete_record(zone:, fqdn:, type:)
40
43
  result = super(zone, fqdn, type)
41
- raise(Error, result.to_s) if result.is_a?(NS1::Response::Error)
44
+ raise_if_error!(result)
42
45
  nil
43
46
  end
47
+
48
+ private
49
+
50
+ def raise_if_error!(result)
51
+ return unless result.is_a?(NS1::Response::Error)
52
+ if result.is_a?(NS1::Response::UnparsableBodyError)
53
+ raise RecordStore::Provider::UnparseableBodyError, result.to_s
54
+ end
55
+ raise RecordStore::Provider::Error, result.to_s
56
+ end
44
57
  end
45
58
  end
46
59
  end
@@ -1,25 +1,41 @@
1
1
  require 'net/http'
2
- require_relative '../provider_utils/rate_limit'
2
+ require_relative '../provider_utils/waiter'
3
+
4
+ class NS1::Response::UnparsableBodyError < NS1::Response::Error
5
+ def initialize(status)
6
+ @status = status
7
+ super({}, status)
8
+ end
9
+ end
3
10
 
4
11
  # Patch the method which retrieves headers for API rate limit dynamically
5
12
  module NS1::Transport
6
13
  class NetHttp
14
+ X_RATELIMIT_PERIOD = 'x-ratelimit-period'.freeze
15
+ X_RATELIMIT_REMAINING = 'x-ratelimit-remaining'.freeze
16
+
7
17
  def process_response(response)
8
- sleep_time = response.to_hash['x-ratelimit-period'].first.to_i /
9
- [1, response.to_hash['x-ratelimit-remaining'].first.to_i].max.to_f
18
+ response_hash = response.to_hash
19
+
20
+ if response_hash.key?(X_RATELIMIT_PERIOD) && response_hash.key?(X_RATELIMIT_REMAINING)
21
+ sleep_time = response_hash[X_RATELIMIT_PERIOD].first.to_i /
22
+ [1, response_hash[X_RATELIMIT_REMAINING].first.to_i].max.to_f
10
23
 
11
- rate_limit = RateLimit.new('NS1')
12
- rate_limit.sleep_for(sleep_time)
24
+ rate_limit = RateLimitWaiter.new('NS1')
25
+ rate_limit.wait(sleep_time)
26
+ end
13
27
 
14
- body = JSON.parse(response.body)
15
- case response
16
- when Net::HTTPOK
17
- NS1::Response::Success.new(body, response.code.to_i)
18
- else
19
- NS1::Response::Error.new(body, response.code.to_i)
28
+ begin
29
+ body = JSON.parse(response.body)
30
+ case response
31
+ when Net::HTTPOK
32
+ NS1::Response::Success.new(body, response.code.to_i)
33
+ else
34
+ NS1::Response::Error.new(body, response.code.to_i)
35
+ end
36
+ rescue JSON::ParserError
37
+ NS1::Response::UnparsableBodyError.new(response.code.to_i)
20
38
  end
21
- rescue JSON::ParserError
22
- raise NS1::Transport::ResponseParseError
23
39
  end
24
40
  end
25
41
  end
@@ -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, message: @message)
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(message: @message)
38
+ super(@current_delay, message: message)
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.1.1'.freeze
2
+ VERSION = '6.3.1'.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
@@ -44,13 +45,18 @@ module RecordStore
44
45
  end
45
46
  end
46
47
 
47
- MAX_PARALLEL_THREADS = 10
48
+ DEFAULT_MAX_PARALLEL_THREADS = 10
49
+
50
+ def max_parallel_threads
51
+ (ENV['RECORD_STORE_MAX_THREADS'] || DEFAULT_MAX_PARALLEL_THREADS).to_i
52
+ end
53
+
48
54
  def modified(verbose: false) # rubocop:disable Lint/UnusedMethodArgument
49
55
  modified_zones = []
50
56
  mutex = Mutex.new
51
57
  zones = all
52
58
 
53
- (1..MAX_PARALLEL_THREADS).map do
59
+ (1..max_parallel_threads).map do
54
60
  Thread.new do
55
61
  current_zone = nil
56
62
  while zones.any?
@@ -258,6 +264,28 @@ module RecordStore
258
264
  end
259
265
  end
260
266
 
267
+ def validate_no_empty_non_terminal
268
+ return unless config.empty_non_terminal_over_wildcard?
269
+
270
+ wildcards = records.select(&:wildcard?).map(&:fqdn).uniq
271
+ wildcards.each do |wildcard|
272
+ suffix = wildcard[1..-1]
273
+
274
+ terminal_records = records.map(&:fqdn)
275
+ .select { |record| record.match?(/^([a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_])#{Regexp.escape(suffix)}$/) }
276
+ next unless terminal_records.any?
277
+
278
+ intermediate_records = records.map(&:fqdn)
279
+ .select { |record| record.match?(/^([a-zA-Z0-9\-_]+)#{Regexp.escape(suffix)}$/) }
280
+ terminal_records.each do |terminal_record|
281
+ non_terminal = terminal_record.partition('.').last
282
+ errors.add(:records, "found empty non-terminal #{non_terminal} "\
283
+ "(caused by existing records #{wildcard} and #{terminal_record})")\
284
+ unless intermediate_records.include?(non_terminal)
285
+ end
286
+ end
287
+ end
288
+
261
289
  def validate_can_handle_alias_records
262
290
  return unless records.any? { |record| record.is_a?(Record::ALIAS) }
263
291
  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,
@@ -48,6 +48,6 @@ Gem::Specification.new do |spec|
48
48
  spec.add_development_dependency 'vcr'
49
49
  spec.add_development_dependency 'pry'
50
50
  spec.add_development_dependency 'webmock'
51
- spec.add_development_dependency 'rubocop'
51
+ spec.add_development_dependency 'rubocop', '0.89.1'
52
52
  spec.add_development_dependency 'minitest-focus'
53
53
  end
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.1.1
4
+ version: 6.3.1
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-06-11 00:00:00.000000000 Z
12
+ date: 2020-10-09 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: thor
@@ -301,16 +301,16 @@ dependencies:
301
301
  name: rubocop
302
302
  requirement: !ruby/object:Gem::Requirement
303
303
  requirements:
304
- - - ">="
304
+ - - '='
305
305
  - !ruby/object:Gem::Version
306
- version: '0'
306
+ version: 0.89.1
307
307
  type: :development
308
308
  prerelease: false
309
309
  version_requirements: !ruby/object:Gem::Requirement
310
310
  requirements:
311
- - - ">="
311
+ - - '='
312
312
  - !ruby/object:Gem::Version
313
- version: '0'
313
+ version: 0.89.1
314
314
  - !ruby/object:Gem::Dependency
315
315
  name: minitest-focus
316
316
  requirement: !ruby/object:Gem::Requirement
@@ -362,7 +362,7 @@ files:
362
362
  - lib/record_store/provider/ns1/client.rb
363
363
  - lib/record_store/provider/ns1/patch_api_header.rb
364
364
  - lib/record_store/provider/oracle_cloud_dns.rb
365
- - lib/record_store/provider/provider_utils/rate_limit.rb
365
+ - lib/record_store/provider/provider_utils/waiter.rb
366
366
  - lib/record_store/record.rb
367
367
  - lib/record_store/record/a.rb
368
368
  - lib/record_store/record/aaaa.rb
@@ -1,14 +0,0 @@
1
- class RateLimit
2
- def initialize(provider)
3
- @provider = provider
4
- end
5
-
6
- def sleep_for(sleep_time)
7
- while sleep_time > 0
8
- wait = [10, sleep_time].min
9
- puts "Waiting on #{@provider} rate-limit" if wait > 1
10
- sleep(wait)
11
- sleep_time -= wait
12
- end
13
- end
14
- end