record_store 6.1.1 → 6.3.1

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: 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