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 +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/CHANGELOG.md +7 -0
- data/README.md +2 -0
- data/lib/record_store/provider/cloudflare.rb +80 -30
- data/lib/record_store/record.rb +8 -1
- data/lib/record_store/version.rb +1 -1
- data/lib/record_store/zone/config/implicit_record_template.rb +53 -7
- data/lib/record_store/zone.rb +25 -4
- data/lib/record_store.rb +1 -0
- data/record_store.gemspec +2 -2
- metadata +6 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: effd0589fd20b2602042b321bd4f82d00b1f9fe08aa67f02b88e7b798d217559
|
|
4
|
+
data.tar.gz: 051e18aa4b65ac9ff67824027c1fcf83ebc66222dbd9b244ed7ae8f5914f8fce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 237530be7cc7ee854275117be9d21d3e039eaf17fd45b73bb7cc56ecc7723bff2c531712021c263db2c87ef2eeca3d7a84fae18341a33615c4cf70af0ce66f77
|
|
7
|
+
data.tar.gz: 9ed803f7f7aad03e8a45bd46e0e0a8e1f398fdf7f70479b5154cb06b2efebd36cbc77cbeb2d150ed0374610dc995c14afd7ee057d9d28ce7bff3e96a71240623
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -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@
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
matching_zones
|
|
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
|
data/lib/record_store/record.rb
CHANGED
|
@@ -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.
|
|
91
|
+
self.class.type_name.dup
|
|
85
92
|
end
|
|
86
93
|
|
|
87
94
|
def ==(other)
|
data/lib/record_store/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
88
|
+
current_records: current_records,
|
|
89
|
+
records_to_inject: records_to_inject,
|
|
90
|
+
present: present,
|
|
65
91
|
)
|
|
66
92
|
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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) }
|
data/lib/record_store/zone.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
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', '<
|
|
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.
|
|
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:
|
|
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: '
|
|
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: '
|
|
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.
|
|
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.
|
|
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:
|
|
464
|
+
rubygems_version: 4.0.23
|
|
465
465
|
specification_version: 4
|
|
466
466
|
summary: Manage DNS using git
|
|
467
467
|
test_files: []
|