record_store 9.0.0 → 9.1.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: ce291e6fbcef26df114b7bbf86e5e2ebd21bfdccf8734f5a9c68976ef92bdf2b
4
- data.tar.gz: 2138a7322d533f1de25e2bd7dc69a5046acfda2f352520a64cbccf209f8a7b35
3
+ metadata.gz: effd0589fd20b2602042b321bd4f82d00b1f9fe08aa67f02b88e7b798d217559
4
+ data.tar.gz: 051e18aa4b65ac9ff67824027c1fcf83ebc66222dbd9b244ed7ae8f5914f8fce
5
5
  SHA512:
6
- metadata.gz: ec4b883bb2bde7c4803ddd623a4ff95866a7d0505e1493f76af61abd6d904f2f81f40791a7902bbb380def97e82320cc0d1c57ba54fe02d87c726f41e6cb7f51
7
- data.tar.gz: 5f2e9b36f150961bbb40df71c3aa917e8572d509ecb28aa547b75442adacba8c2f3cc7d642469a0b63402b09334d747c7bdf3245b264b233dae37daea22fc96c
6
+ metadata.gz: 237530be7cc7ee854275117be9d21d3e039eaf17fd45b73bb7cc56ecc7723bff2c531712021c263db2c87ef2eeca3d7a84fae18341a33615c4cf70af0ce66f77
7
+ data.tar.gz: 9ed803f7f7aad03e8a45bd46e0e0a8e1f398fdf7f70479b5154cb06b2efebd36cbc77cbeb2d150ed0374610dc995c14afd7ee057d9d28ce7bff3e96a71240623
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 9.1.0
4
+ - Load zones faster. Implicit record templates no longer rescan every record for each templated record, the duplicate record check no longer compares every pair of records, and `Record#type` is computed once per class. Zones, records and validation errors are unchanged. Loading and validating a repository with several hundred zones is several times faster.
5
+ - Cloudflare: look up zone ids from one listing of all zones, fetched once per run and shared by all threads, instead of one request per zone each time a zone is read or written. A zone that is not in the listing is looked up on its own. A zone that does not exist now fails with `Zone not found for <name>`; before, it failed with `undefined method 'size' for nil`.
6
+
3
7
  ## 9.0.0
4
8
  - Only load implicit record templates by plain file name from directly inside `implicit_records_templates_path`. Template names containing path separators, `.` or `..`, and symlinks that resolve outside that directory now raise an error. Zone definitions that referenced templates this way must move them into the templates directory and use the plain file name.
5
9
 
@@ -2,6 +2,12 @@ require_relative 'cloudflare/client'
2
2
 
3
3
  module RecordStore
4
4
  class Provider::Cloudflare < Provider
5
+ # Cloudflare returns at most 50 zones per page.
6
+ ZONE_LIST_PER_PAGE = 50
7
+
8
+ ZONE_IDS_MUTEX = Mutex.new
9
+ private_constant :ZONE_IDS_MUTEX
10
+
5
11
  class << self
6
12
  def record_types
7
13
  super | Set.new(%w(PTR))
@@ -45,25 +51,13 @@ module RecordStore
45
51
  # Cloudflare returns zones across all accounts accessible by the API token
46
52
  # Can implement filtering in request if needed
47
53
  def zones
48
- all_zones = []
49
- page = 1
50
- per_page = 50
51
-
52
- loop do
53
- response = nil
54
- retry_on_connection_errors do
55
- response = client.get('/client/v4/zones', page: page, per_page: per_page)
56
- end
57
-
58
- zones = response.result_raw || []
59
- all_zones.concat(zones)
60
-
61
- break if page * per_page >= response.result_info_raw['total_count']
62
-
63
- page += 1
64
- end
54
+ list_zones.map { |zone| zone['name'] }
55
+ end
65
56
 
66
- all_zones.map { |zone| zone['name'] }
57
+ # Forgets the zone name => id mapping, so the next lookup lists the zones again. The mapping is kept for the
58
+ # life of the process, which is a single run.
59
+ def clear_zone_id_cache
60
+ ZONE_IDS_MUTEX.synchronize { @zone_ids = nil }
67
61
  end
68
62
 
69
63
  def apply_changeset(changeset, stdout = $stdout)
@@ -204,20 +198,76 @@ module RecordStore
204
198
  )
205
199
  end
206
200
 
201
+ # Returns every zone the token can see, as the raw API hashes.
202
+ def list_zones
203
+ all_zones = []
204
+ page = 1
205
+
206
+ loop do
207
+ response = nil
208
+ retry_on_connection_errors do
209
+ response = client.get('/client/v4/zones', page: page, per_page: ZONE_LIST_PER_PAGE)
210
+ end
211
+
212
+ zones = response.result_raw || []
213
+ all_zones.concat(zones)
214
+
215
+ break if page * ZONE_LIST_PER_PAGE >= response.result_info_raw['total_count']
216
+
217
+ page += 1
218
+ end
219
+
220
+ all_zones
221
+ end
222
+
223
+ # Zone ids are looked up from one listing of all zones, which is fetched on first use and shared by every
224
+ # thread. A zone that is not in that listing (for example one created after it was fetched) is looked up on its
225
+ # own, and the answer is remembered.
207
226
  def zone_name_to_id(zone_name)
227
+ key = zone_id_cache_key(zone_name)
228
+ ids = zone_ids_by_name[key]
229
+
230
+ if ids.nil?
231
+ ids = zone_ids_from_lookup(zone_name)
232
+ remember_zone_ids(key, ids) if ids.size == 1
233
+ end
234
+
235
+ case ids.size
236
+ when 0
237
+ raise "Zone not found for #{zone_name}"
238
+ when 1
239
+ ids.first
240
+ else
241
+ raise "Multiple zones found for #{zone_name}. " \
242
+ "API key must only return the zone on which records are to be managed."
243
+ end
244
+ end
245
+
246
+ def zone_id_cache_key(zone_name)
247
+ zone_name.to_s.chomp('.').downcase
248
+ end
249
+
250
+ # name => [id, ...] for every zone in the account(s). Several ids under one name means the name is ambiguous.
251
+ # A zone created while the listing is being paged can show up on two pages, hence the uniq.
252
+ def zone_ids_by_name
253
+ @zone_ids || ZONE_IDS_MUTEX.synchronize do
254
+ @zone_ids ||= list_zones
255
+ .group_by { |zone| zone_id_cache_key(zone['name']) }
256
+ .transform_values { |zones| zones.map { |zone| zone['id'] }.uniq }
257
+ .freeze
258
+ end
259
+ end
260
+
261
+ def remember_zone_ids(key, ids)
262
+ ZONE_IDS_MUTEX.synchronize do
263
+ @zone_ids = @zone_ids.merge(key => ids.freeze).freeze if @zone_ids
264
+ end
265
+ end
266
+
267
+ def zone_ids_from_lookup(zone_name)
208
268
  retry_on_connection_errors do
209
- params = { name: zone_name }
210
- matching_zones = client.get("/client/v4/zones", params).result_raw
211
-
212
- case matching_zones.size
213
- when 0
214
- raise "Zone not found for #{zone_name}"
215
- when 1
216
- matching_zones.first['id']
217
- else
218
- raise "Multiple zones found for #{zone_name}. " \
219
- "API key must only return the zone on which records are to be managed."
220
- end
269
+ matching_zones = client.get("/client/v4/zones", name: zone_name).result_raw || []
270
+ matching_zones.map { |zone| zone['id'] }
221
271
  end
222
272
  end
223
273
  end
@@ -42,6 +42,12 @@ module RecordStore
42
42
  unescape(value.sub(/\A"(.*)"\z/, '\1'))
43
43
  end
44
44
 
45
+ # The record type, e.g. "A" for Record::A. Called millions of times when loading zones, so it is computed once
46
+ # per class.
47
+ def type_name
48
+ @type_name ||= name.demodulize.freeze
49
+ end
50
+
45
51
  def ensure_ends_with_dot(fqdn)
46
52
  fqdn.end_with?(".") ? fqdn : "#{fqdn}."
47
53
  end
@@ -80,8 +86,9 @@ module RecordStore
80
86
  }.merge(rdata)
81
87
  end
82
88
 
89
+ # Returns a new string each time, as it always has: callers such as fog-dynect modify it in place.
83
90
  def type
84
- self.class.name.demodulize
91
+ self.class.type_name.dup
85
92
  end
86
93
 
87
94
  def ==(other)
@@ -1,3 +1,3 @@
1
1
  module RecordStore
2
- VERSION = '9.0.0'.freeze
2
+ VERSION = '9.1.0'.freeze
3
3
  end
@@ -75,18 +75,26 @@ module RecordStore
75
75
  end
76
76
 
77
77
  def generate_records_to_inject(current_records:)
78
+ # Every [type, fqdn] pair currently in the zone, including what has been injected so far. Conflict filters
79
+ # that only name a type and an fqdn are answered from this set instead of scanning every record.
80
+ present = Set.new(current_records.map { |record| [record.type, record.fqdn] })
81
+
78
82
  current_records
79
83
  .select { |record| should_template?(record: record) }
80
84
  .map { |record| template_record_for(record: record, current_records: current_records) }
81
85
  .each_with_object([]) do |template_records, records_to_inject|
82
86
  next unless should_inject?(
83
87
  template_records: template_records,
84
- current_records: current_records + records_to_inject,
88
+ current_records: current_records,
89
+ records_to_inject: records_to_inject,
90
+ present: present,
85
91
  )
86
92
 
87
- records_to_inject.push(
88
- *template_records.fetch(:injected_records, []).map { |r_yml| Record.build_from_yaml_definition(r_yml) },
89
- )
93
+ new_records = template_records.fetch(:injected_records, []).map do |r_yml|
94
+ Record.build_from_yaml_definition(r_yml)
95
+ end
96
+ new_records.each { |record| present << [record.type, record.fqdn] }
97
+ records_to_inject.push(*new_records)
90
98
  end
91
99
  end
92
100
 
@@ -94,15 +102,33 @@ module RecordStore
94
102
 
95
103
  attr_reader :template, :filters_for_records_to_template, :filters_for_records_to_exclude
96
104
 
97
- def should_inject?(template_records:, current_records:)
105
+ def should_inject?(template_records:, current_records:, records_to_inject:, present:)
98
106
  conflict_with = template_records[:conflict_with] || []
99
- current_records.none? do |record|
107
+
108
+ if conflict_with.all? { |filter| type_and_fqdn_filter?(filter) }
109
+ conflict_with.none? { |filter| present.include?([filter[:type], filter[:fqdn]]) }
110
+ else
111
+ !conflicts_with_any?(conflict_with: conflict_with, records: current_records + records_to_inject)
112
+ end
113
+ end
114
+
115
+ # The general check: any record matching any filter is a conflict. Used for filters that match on anything
116
+ # other than an exact type and fqdn (regexps, other attributes).
117
+ def conflicts_with_any?(conflict_with:, records:)
118
+ records.any? do |record|
100
119
  conflict_with.any? do |filter|
101
120
  record_match?(record: record, filter: filter)
102
121
  end
103
122
  end
104
123
  end
105
124
 
125
+ def type_and_fqdn_filter?(filter)
126
+ filter.is_a?(Hash) &&
127
+ filter.keys.sort == [:fqdn, :type] &&
128
+ filter[:type].is_a?(String) &&
129
+ filter[:fqdn].is_a?(String)
130
+ end
131
+
106
132
  def should_template?(record:)
107
133
  filters_for_records_to_template.any? { |filter| record_match?(record: record, filter: filter) } &&
108
134
  filters_for_records_to_exclude.none? { |filter| record_match?(record: record, filter: filter) }
@@ -259,9 +259,24 @@ module RecordStore
259
259
  end
260
260
 
261
261
  def validate_all_records_are_unique
262
- duplicates = records.select { |r| records.count(r) > 1 }
263
- duplicates.uniq.each do |record|
264
- errors.add(:records, "Duplicate record: #{record}")
262
+ if records.all? { |record| comparable_by_hash?(record) }
263
+ # Hash keys keep first-seen order, so each duplicated record is reported once, in the order it first appears.
264
+ records.tally.each do |record, count|
265
+ errors.add(:records, "Duplicate record: #{record}") if count > 1
266
+ end
267
+ else
268
+ duplicates = records.select { |r| records.count(r) > 1 }
269
+ duplicates.uniq.each do |record|
270
+ errors.add(:records, "Duplicate record: #{record}")
271
+ end
272
+ end
273
+ end
274
+
275
+ # Record#== compares to_hash with ==, but tally and uniq use Record#hash and eql?, which tell 1 from 1.0. The two
276
+ # agree when every value is a string, an Integer, a boolean or nil, so only then is tallying safe.
277
+ def comparable_by_hash?(record)
278
+ record.to_hash.each_value.all? do |value|
279
+ value.is_a?(String) || value.is_a?(Integer) || value == true || value == false || value.nil?
265
280
  end
266
281
  end
267
282
 
data/lib/record_store.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require 'json'
2
+ require 'set'
2
3
  require 'yaml'
3
4
  require 'active_support'
4
5
  require 'active_support/core_ext/array/wrap'
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: 9.0.0
4
+ version: 9.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Willem van Bergen
@@ -461,7 +461,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
461
461
  - !ruby/object:Gem::Version
462
462
  version: '0'
463
463
  requirements: []
464
- rubygems_version: 4.0.21
464
+ rubygems_version: 4.0.23
465
465
  specification_version: 4
466
466
  summary: Manage DNS using git
467
467
  test_files: []