record_store 8.0.11 → 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: 43d93495d6304ff99a4c2335be9452a7dd8a222580d702536e21d3cf836e0607
4
- data.tar.gz: f1f948d4136c09ecca1a6199eda17aea70bb726a3a75e8aa224d0ee3b2df13a3
3
+ metadata.gz: effd0589fd20b2602042b321bd4f82d00b1f9fe08aa67f02b88e7b798d217559
4
+ data.tar.gz: 051e18aa4b65ac9ff67824027c1fcf83ebc66222dbd9b244ed7ae8f5914f8fce
5
5
  SHA512:
6
- metadata.gz: 7e143587f1505ee799b3849d1fdcec2e58563fd5ffb9e75154e4277bef465bf8e76aec116aeb9e418eb81441a46132ab34c8806c39266cc4c7db4f92c6125907
7
- data.tar.gz: c92a3b36d67f4cec13ba82aa3c7679803188c1e56e79917fadccc82cd0c856d6bdefd0beb4892ca050c7005579d4e0ec888fe35cc5e9850c464d3cde23a5c542
6
+ metadata.gz: 237530be7cc7ee854275117be9d21d3e039eaf17fd45b73bb7cc56ecc7723bff2c531712021c263db2c87ef2eeca3d7a84fae18341a33615c4cf70af0ce66f77
7
+ data.tar.gz: 9ed803f7f7aad03e8a45bd46e0e0a8e1f398fdf7f70479b5154cb06b2efebd36cbc77cbeb2d150ed0374610dc995c14afd7ee057d9d28ce7bff3e96a71240623
@@ -14,7 +14,7 @@ jobs:
14
14
  ruby: [ 3.2, 3.3, 3.4 ]
15
15
  name: Test Ruby ${{ matrix.ruby }}
16
16
  steps:
17
- - uses: actions/checkout@v6
17
+ - uses: actions/checkout@v7
18
18
  - name: Install dependencies
19
19
  run: |
20
20
  sudo apt-get update && sudo apt-get install build-essential libcurl4-openssl-dev
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
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
+
7
+ ## 9.0.0
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.
9
+
3
10
  ## 8.0.11
4
11
  - Modified Zone#fetch_authority to handle SOA answer sections from authoritative servers
5
12
 
data/README.md CHANGED
@@ -135,6 +135,8 @@ Record Store supports injecting implicit records into a zone based on templates.
135
135
 
136
136
  Templates can help reduce zone file bloat where instead of defining many generic literals within the zone file for a given criteria zone record, these generic records can be implicitly injected into the `Zone` at the time of initialization based on information provided within the template.
137
137
 
138
+ Zone definitions reference templates by plain file name (for example `implicit_records_templates: [acme-challenge.yml.erb]`), and only files directly inside `implicit_records_templates_path` can be loaded. Names containing path separators, `.` or `..`, and symlinks that resolve outside that directory are rejected. Templates are rendered as ERB, so treat the templates directory as code: restrict who can change it to people you would trust to change Record Store itself.
139
+
138
140
  ----
139
141
 
140
142
  # Development
@@ -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 = '8.0.11'.freeze
2
+ VERSION = '9.1.0'.freeze
3
3
  end
@@ -25,6 +25,8 @@ module RecordStore
25
25
  end
26
26
 
27
27
  class ImplicitRecordTemplate
28
+ class InvalidTemplateFilename < ArgumentError; end
29
+
28
30
  class << self
29
31
  def from_file(filename:)
30
32
  filepath = template_filepath_for(filename: filename)
@@ -43,8 +45,26 @@ module RecordStore
43
45
 
44
46
  private
45
47
 
48
+ # Templates are rendered as ERB, so only files directly inside the
49
+ # configured templates directory may be loaded. Zone definitions name a
50
+ # template by its plain file name; anything that could resolve elsewhere
51
+ # (path separators, `.`/`..`, or a symlink out of the directory) is rejected.
46
52
  def template_filepath_for(filename:)
47
- "#{RecordStore.implicit_records_templates_path}/#{filename}"
53
+ name = filename.to_s
54
+ base = Pathname.new(RecordStore.implicit_records_templates_path).realpath
55
+
56
+ if name.empty? || name == '.' || name == '..' || name != File.basename(name)
57
+ raise InvalidTemplateFilename,
58
+ "implicit record template #{filename.inspect} must be a plain file name in #{base}"
59
+ end
60
+
61
+ path = base.join(name).realpath
62
+ unless path.dirname == base
63
+ raise InvalidTemplateFilename,
64
+ "implicit record template #{filename.inspect} resolves outside #{base}"
65
+ end
66
+
67
+ path.to_s
48
68
  end
49
69
  end
50
70
 
@@ -55,18 +75,26 @@ module RecordStore
55
75
  end
56
76
 
57
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
+
58
82
  current_records
59
83
  .select { |record| should_template?(record: record) }
60
84
  .map { |record| template_record_for(record: record, current_records: current_records) }
61
85
  .each_with_object([]) do |template_records, records_to_inject|
62
86
  next unless should_inject?(
63
87
  template_records: template_records,
64
- current_records: current_records + records_to_inject,
88
+ current_records: current_records,
89
+ records_to_inject: records_to_inject,
90
+ present: present,
65
91
  )
66
92
 
67
- records_to_inject.push(
68
- *template_records.fetch(:injected_records, []).map { |r_yml| Record.build_from_yaml_definition(r_yml) },
69
- )
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)
70
98
  end
71
99
  end
72
100
 
@@ -74,15 +102,33 @@ module RecordStore
74
102
 
75
103
  attr_reader :template, :filters_for_records_to_template, :filters_for_records_to_exclude
76
104
 
77
- def should_inject?(template_records:, current_records:)
105
+ def should_inject?(template_records:, current_records:, records_to_inject:, present:)
78
106
  conflict_with = template_records[:conflict_with] || []
79
- 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|
80
119
  conflict_with.any? do |filter|
81
120
  record_match?(record: record, filter: filter)
82
121
  end
83
122
  end
84
123
  end
85
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
+
86
132
  def should_template?(record:)
87
133
  filters_for_records_to_template.any? { |filter| record_match?(record: record, filter: filter) } &&
88
134
  filters_for_records_to_exclude.none? { |filter| record_match?(record: record, filter: filter) }
@@ -80,7 +80,7 @@ module RecordStore
80
80
 
81
81
  def initialize(name:, records: [], config: {}, abstract_syntax_trees: {})
82
82
  @name = Record.ensure_ends_with_dot(name)
83
- @config = RecordStore::Zone::Config.new(**config.deep_symbolize_keys)
83
+ @config = build_config(config)
84
84
  @records = build_records(records)
85
85
  @abstract_syntax_trees = abstract_syntax_trees
86
86
  end
@@ -224,6 +224,12 @@ module RecordStore
224
224
  end
225
225
  end
226
226
 
227
+ def build_config(config)
228
+ RecordStore::Zone::Config.new(**config.deep_symbolize_keys)
229
+ rescue Config::ImplicitRecordTemplate::InvalidTemplateFilename => e
230
+ raise e.exception("zone #{unrooted_name}: #{e.message}")
231
+ end
232
+
227
233
  def build_records(records)
228
234
  all_records = records.map { |record| Record.build_from_yaml_definition(record) }
229
235
 
@@ -253,9 +259,24 @@ module RecordStore
253
259
  end
254
260
 
255
261
  def validate_all_records_are_unique
256
- duplicates = records.select { |r| records.count(r) > 1 }
257
- duplicates.uniq.each do |record|
258
- 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?
259
280
  end
260
281
  end
261
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'
data/record_store.gemspec CHANGED
@@ -32,7 +32,7 @@ Gem::Specification.new do |spec|
32
32
  spec.add_runtime_dependency 'ejson'
33
33
  spec.add_runtime_dependency 'thor', '>= 1.4.0'
34
34
 
35
- spec.add_runtime_dependency 'dnsimple', '>= 4.4', '< 11.2'
35
+ spec.add_runtime_dependency 'dnsimple', '>= 4.4', '< 12.3'
36
36
  spec.add_runtime_dependency 'fog-dynect', '>= 0.4', '< 0.6'
37
37
  spec.add_runtime_dependency 'fog-json'
38
38
  spec.add_runtime_dependency 'fog-xml'
@@ -47,7 +47,7 @@ Gem::Specification.new do |spec|
47
47
  spec.add_development_dependency 'mocha'
48
48
  spec.add_development_dependency 'pry'
49
49
  spec.add_development_dependency 'rake'
50
- spec.add_development_dependency 'rubocop', '~> 1.82.1'
50
+ spec.add_development_dependency 'rubocop', '~> 1.88.0'
51
51
  spec.add_development_dependency 'rubocop-shopify', '~> 2.15.1'
52
52
  spec.add_development_dependency 'vcr'
53
53
  spec.add_development_dependency 'webmock'
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: 8.0.11
4
+ version: 9.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Willem van Bergen
@@ -89,7 +89,7 @@ dependencies:
89
89
  version: '4.4'
90
90
  - - "<"
91
91
  - !ruby/object:Gem::Version
92
- version: '11.2'
92
+ version: '12.3'
93
93
  type: :runtime
94
94
  prerelease: false
95
95
  version_requirements: !ruby/object:Gem::Requirement
@@ -99,7 +99,7 @@ dependencies:
99
99
  version: '4.4'
100
100
  - - "<"
101
101
  - !ruby/object:Gem::Version
102
- version: '11.2'
102
+ version: '12.3'
103
103
  - !ruby/object:Gem::Dependency
104
104
  name: fog-dynect
105
105
  requirement: !ruby/object:Gem::Requirement
@@ -312,14 +312,14 @@ dependencies:
312
312
  requirements:
313
313
  - - "~>"
314
314
  - !ruby/object:Gem::Version
315
- version: 1.82.1
315
+ version: 1.88.0
316
316
  type: :development
317
317
  prerelease: false
318
318
  version_requirements: !ruby/object:Gem::Requirement
319
319
  requirements:
320
320
  - - "~>"
321
321
  - !ruby/object:Gem::Version
322
- version: 1.82.1
322
+ version: 1.88.0
323
323
  - !ruby/object:Gem::Dependency
324
324
  name: rubocop-shopify
325
325
  requirement: !ruby/object:Gem::Requirement
@@ -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: 3.7.2
464
+ rubygems_version: 4.0.23
465
465
  specification_version: 4
466
466
  summary: Manage DNS using git
467
467
  test_files: []