record_store 6.3.1 → 6.7.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +17 -0
  3. data/.github/workflows/ci.yml +31 -0
  4. data/.github/workflows/cla.yml +23 -0
  5. data/.github/workflows/stale-action-handling.yml +31 -0
  6. data/.rubocop-https---shopify-github-io-ruby-style-guide-rubocop-yml +10 -5
  7. data/.rubocop.yml +26 -5
  8. data/.travis.yml +1 -1
  9. data/CHANGELOG.md +52 -0
  10. data/README.md +13 -0
  11. data/bin/console +1 -0
  12. data/bin/rubocop +4 -2
  13. data/dev.yml +1 -1
  14. data/lib/record_store/changeset.rb +22 -18
  15. data/lib/record_store/cli.rb +13 -14
  16. data/lib/record_store/provider/dnsimple/patch_request_error_to_include_errors.rb +20 -0
  17. data/lib/record_store/provider/dnsimple.rb +10 -11
  18. data/lib/record_store/provider/dynect.rb +5 -7
  19. data/lib/record_store/provider/google_cloud_dns.rb +9 -13
  20. data/lib/record_store/provider/ns1/client.rb +3 -1
  21. data/lib/record_store/provider/ns1/patch_api_header.rb +1 -1
  22. data/lib/record_store/provider/ns1.rb +11 -8
  23. data/lib/record_store/provider/oracle_cloud_dns.rb +4 -3
  24. data/lib/record_store/provider.rb +23 -19
  25. data/lib/record_store/record/alias.rb +1 -1
  26. data/lib/record_store/record/caa.rb +3 -1
  27. data/lib/record_store/record/cname.rb +1 -1
  28. data/lib/record_store/record.rb +6 -4
  29. data/lib/record_store/version.rb +1 -1
  30. data/lib/record_store/zone/config/implicit_record_template.rb +112 -0
  31. data/lib/record_store/zone/config.rb +5 -3
  32. data/lib/record_store/zone/yaml_definitions.rb +7 -2
  33. data/lib/record_store/zone.rb +80 -18
  34. data/lib/record_store.rb +15 -2
  35. data/record_store.gemspec +14 -14
  36. data/template/config.yml +1 -0
  37. data/template/templates/implicit_records/implicit_example.yml.erb +15 -0
  38. data/template/zones/dynect.example.com.yml +2 -0
  39. metadata +95 -70
@@ -13,6 +13,9 @@ module RecordStore
13
13
  rrdata_fields = case type
14
14
  when 'SPF', 'TXT'
15
15
  [answer]
16
+ when 'SRV'
17
+ priority, weight, port, host = answer.split
18
+ [priority.to_i, weight.to_i, port.to_i, Record.ensure_ends_with_dot(host)]
16
19
  else
17
20
  answer.split
18
21
  end
@@ -50,7 +53,7 @@ module RecordStore
50
53
  # Downloads all the records from the provider.
51
54
  #
52
55
  # Returns: an array of `Record` for each record in the provider's zone
53
- def retrieve_current_records(zone:, stdout: $stdout) # rubocop:disable Lint/UnusedMethodArgument
56
+ def retrieve_current_records(zone:, stdout: $stdout)
54
57
  records_for_zone(zone)
55
58
  .flat_map { |short_record| build_from_api(short_record) }
56
59
  .compact
@@ -84,7 +87,7 @@ module RecordStore
84
87
  existing_record = client.record(
85
88
  zone: zone,
86
89
  fqdn: record_fqdn,
87
- type: record.type
90
+ type: record.type,
88
91
  )
89
92
 
90
93
  if existing_record.nil?
@@ -96,7 +99,7 @@ module RecordStore
96
99
  answers: new_answers,
97
100
  ttl: record.ttl,
98
101
  use_client_subnet: false, # only required for filter chains that are not supported by record_store
99
- }
102
+ },
100
103
  )
101
104
  return
102
105
  end
@@ -106,7 +109,7 @@ module RecordStore
106
109
  zone: zone,
107
110
  fqdn: record_fqdn,
108
111
  type: record.type,
109
- params: { answers: existing_answers + new_answers, ttl: record.ttl }
112
+ params: { answers: existing_answers + new_answers, ttl: record.ttl },
110
113
  )
111
114
  end
112
115
 
@@ -120,7 +123,7 @@ module RecordStore
120
123
  existing_record = client.record(
121
124
  zone: zone,
122
125
  fqdn: record_fqdn,
123
- type: record.type
126
+ type: record.type,
124
127
  )
125
128
  return if existing_record.nil?
126
129
 
@@ -132,7 +135,7 @@ module RecordStore
132
135
  client.delete_record(
133
136
  zone: zone,
134
137
  fqdn: record_fqdn,
135
- type: record.type
138
+ type: record.type,
136
139
  )
137
140
  return
138
141
  end
@@ -141,7 +144,7 @@ module RecordStore
141
144
  zone: zone,
142
145
  fqdn: record_fqdn,
143
146
  type: record.type,
144
- params: { answers: pruned_answers }
147
+ params: { answers: pruned_answers },
145
148
  )
146
149
  end
147
150
 
@@ -185,7 +188,7 @@ module RecordStore
185
188
  zone: zone,
186
189
  fqdn: record_fqdn,
187
190
  type: record.type,
188
- params: { answers: existing_record['answers'], ttl: record.ttl }
191
+ params: { answers: existing_record['answers'], ttl: record.ttl },
189
192
  )
190
193
  end
191
194
 
@@ -55,7 +55,7 @@ module RecordStore
55
55
 
56
56
  client.patch_zone_records(
57
57
  zone,
58
- OCI::Dns::Models::PatchZoneRecordsDetails.new(items: patch_add_record)
58
+ OCI::Dns::Models::PatchZoneRecordsDetails.new(items: patch_add_record),
59
59
  )
60
60
  end
61
61
 
@@ -72,6 +72,7 @@ module RecordStore
72
72
  ).data.items.select { |r| r.rdata == record.rdata_txt }
73
73
 
74
74
  return unless found_record
75
+
75
76
  begin
76
77
  record_hash = found_record.first.record_hash if found_record.length == 1
77
78
  rescue NoMethodError
@@ -90,7 +91,7 @@ module RecordStore
90
91
 
91
92
  client.patch_zone_records(
92
93
  zone,
93
- OCI::Dns::Models::PatchZoneRecordsDetails.new(items: patch_remove_record)
94
+ OCI::Dns::Models::PatchZoneRecordsDetails.new(items: patch_remove_record),
94
95
  )
95
96
  end
96
97
  end
@@ -122,7 +123,7 @@ module RecordStore
122
123
  domain: record_fqdn,
123
124
  ttl: record.ttl,
124
125
  rtype: record.type,
125
- rdata: record.rdata_txt
126
+ rdata: record.rdata_txt,
126
127
  )
127
128
  update_zone_record_items.delete_if { |r| id == r.record_hash }
128
129
 
@@ -3,10 +3,12 @@ require 'resolv'
3
3
  module RecordStore
4
4
  class Provider
5
5
  class Error < StandardError; end
6
+
6
7
  class UnparseableBodyError < Error; end
7
8
 
8
9
  class << self
9
10
  def provider_for(object)
11
+ lookup_error = false
10
12
  ns_server =
11
13
  case object
12
14
  when Record::NS
@@ -16,9 +18,10 @@ module RecordStore
16
18
  master_nameserver_for(object)
17
19
  rescue Resolv::ResolvError
18
20
  $stderr.puts "Domain doesn't exist (#{object})"
19
- return
21
+ lookup_error = true
20
22
  end
21
23
  end
24
+ return if lookup_error
22
25
 
23
26
  case ns_server
24
27
  when /\.dnsimple\.com\z/
@@ -152,24 +155,25 @@ module RecordStore
152
155
  )
153
156
 
154
157
  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
158
+ return yield
159
+ rescue UnparseableBodyError
160
+ raise if max_retries <= 0
161
+
162
+ max_retries -= 1
163
+
164
+ waiter.wait(message: 'Waiting to retry after receiving an unparseable response')
165
+ rescue Net::OpenTimeout, Errno::ETIMEDOUT
166
+ raise if max_timeouts <= 0
167
+
168
+ max_timeouts -= 1
169
+
170
+ $stderr.puts('Retrying after a connection timeout')
171
+ rescue Errno::ECONNRESET
172
+ raise if max_conn_resets <= 0
173
+
174
+ max_conn_resets -= 1
175
+
176
+ waiter.wait
173
177
  end
174
178
  end
175
179
  end
@@ -23,7 +23,7 @@ module RecordStore
23
23
  end
24
24
 
25
25
  def validate_circular_reference
26
- errors.add(:fqdn, 'An ALIAS should not point to itself') unless fqdn != @alias
26
+ errors.add(:fqdn, 'An ALIAS should not point to itself') if fqdn == @alias
27
27
  end
28
28
  end
29
29
  end
@@ -7,7 +7,8 @@ module RecordStore
7
7
 
8
8
  validates :flags, presence: true, numericality:
9
9
  {
10
- only_integer: true, greater_than_or_equal_to: 0,
10
+ only_integer: true,
11
+ greater_than_or_equal_to: 0,
11
12
  less_than_or_equal_to: 255
12
13
  }
13
14
  validates :tag, inclusion: { in: %w(issue issuewild iodef) }, presence: true
@@ -42,6 +43,7 @@ module RecordStore
42
43
  def validate_uri_value
43
44
  uri = URI(value)
44
45
  return if uri.is_a?(URI::MailTo) || uri.is_a?(URI::HTTP)
46
+
45
47
  errors.add(:value, "URL scheme should be mailto, http, or https")
46
48
  rescue URI::Error
47
49
  errors.add(:value, "Value should be a valid URI")
@@ -23,7 +23,7 @@ module RecordStore
23
23
  end
24
24
 
25
25
  def validate_circular_reference
26
- errors.add(:fqdn, 'A CNAME should not point to itself') unless fqdn != cname
26
+ errors.add(:fqdn, 'A CNAME should not point to itself') if fqdn == cname
27
27
  end
28
28
  end
29
29
  end
@@ -61,12 +61,14 @@ module RecordStore
61
61
  @id = record.fetch(:record_id, nil)
62
62
  end
63
63
 
64
- def self.build_from_yaml_definition(yaml_definition)
65
- record_type = yaml_definition.fetch(:type)
66
- Record.const_get(record_type).new(yaml_definition)
64
+ class << self
65
+ def build_from_yaml_definition(yaml_definition)
66
+ record_type = yaml_definition.fetch(:type)
67
+ Record.const_get(record_type).new(yaml_definition)
68
+ end
67
69
  end
68
70
 
69
- def log!(logger = STDOUT)
71
+ def log!(logger = $stdout)
70
72
  logger.puts to_s
71
73
  end
72
74
 
@@ -1,3 +1,3 @@
1
1
  module RecordStore
2
- VERSION = '6.3.1'.freeze
2
+ VERSION = '6.7.1'.freeze
3
3
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RecordStore
4
+ class Zone
5
+ class Config
6
+ class TemplateContext
7
+ class << self
8
+ def build(record:, current_records:)
9
+ new(record: record, current_records: current_records)
10
+ end
11
+ end
12
+
13
+ def initialize(record:, current_records:)
14
+ @record = record
15
+ @current_records = current_records
16
+ end
17
+
18
+ def fetch_binding
19
+ binding
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :record, :current_records
25
+ end
26
+
27
+ class ImplicitRecordTemplate
28
+ class << self
29
+ def from_file(filename:)
30
+ filepath = template_filepath_for(filename: filename)
31
+ template_file = File.read(filepath)
32
+
33
+ template_file_yaml = YAML.safe_load(template_file, permitted_classes: [Regexp]).deep_symbolize_keys
34
+ filters_for_records_to_template = template_file_yaml[:each_record]
35
+ filters_for_records_to_exclude = template_file_yaml[:except_record] || []
36
+
37
+ new(
38
+ template: ERB.new(template_file),
39
+ filters_for_records_to_template: filters_for_records_to_template,
40
+ filters_for_records_to_exclude: filters_for_records_to_exclude,
41
+ )
42
+ end
43
+
44
+ private
45
+
46
+ def template_filepath_for(filename:)
47
+ "#{RecordStore.implicit_records_templates_path}/#{filename}"
48
+ end
49
+ end
50
+
51
+ def initialize(template:, filters_for_records_to_template:, filters_for_records_to_exclude:)
52
+ @template = template
53
+ @filters_for_records_to_template = filters_for_records_to_template
54
+ @filters_for_records_to_exclude = filters_for_records_to_exclude
55
+ end
56
+
57
+ def generate_records_to_inject(current_records:)
58
+ current_records
59
+ .select { |record| should_template?(record: record) }
60
+ .map { |record| template_record_for(record: record, current_records: current_records) }
61
+ .each_with_object([]) do |template_records, records_to_inject|
62
+ next unless should_inject?(
63
+ template_records: template_records,
64
+ current_records: current_records + records_to_inject,
65
+ )
66
+
67
+ records_to_inject.push(
68
+ *template_records.fetch(:injected_records, []).map { |r_yml| Record.build_from_yaml_definition(r_yml) },
69
+ )
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ attr_reader :template, :filters_for_records_to_template, :filters_for_records_to_exclude
76
+
77
+ def should_inject?(template_records:, current_records:)
78
+ conflict_with = template_records[:conflict_with] || []
79
+ current_records.none? do |record|
80
+ conflict_with.any? do |filter|
81
+ record_match?(record: record, filter: filter)
82
+ end
83
+ end
84
+ end
85
+
86
+ def should_template?(record:)
87
+ filters_for_records_to_template.any? { |filter| record_match?(record: record, filter: filter) } &&
88
+ filters_for_records_to_exclude.none? { |filter| record_match?(record: record, filter: filter) }
89
+ end
90
+
91
+ def record_match?(record:, filter:)
92
+ filter.all? do |key, value|
93
+ if value.is_a?(Regexp)
94
+ value.match(record.public_send(key))
95
+ else
96
+ record.public_send(key) == value
97
+ end
98
+ end
99
+ end
100
+
101
+ def template_record_for(record:, current_records:)
102
+ context = TemplateContext.build(record: record, current_records: current_records)
103
+
104
+ YAML.safe_load(
105
+ template.result(context.fetch_binding),
106
+ permitted_classes: [Regexp],
107
+ ).deep_symbolize_keys
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -3,15 +3,17 @@ module RecordStore
3
3
  class Config
4
4
  include ActiveModel::Validations
5
5
 
6
- attr_reader :ignore_patterns, :providers, :supports_alias
6
+ attr_reader :ignore_patterns, :providers, :supports_alias, :implicit_records_templates
7
7
 
8
8
  validate :validate_zone_config
9
9
 
10
- def initialize(ignore_patterns: [], providers: nil, supports_alias: nil)
10
+ def initialize(ignore_patterns: [], providers: nil, supports_alias: nil, implicit_records_templates: [])
11
11
  @ignore_patterns = ignore_patterns.map do |ignore_pattern|
12
12
  Zone::Config::IgnorePattern.new(ignore_pattern)
13
13
  end
14
-
14
+ @implicit_records_templates = implicit_records_templates.map do |filename|
15
+ Zone::Config::ImplicitRecordTemplate.from_file(filename: filename)
16
+ end
15
17
  @providers = providers
16
18
  @supports_alias = supports_alias
17
19
  end
@@ -31,7 +31,8 @@ module RecordStore
31
31
  end
32
32
 
33
33
  def write(name, config:, records:, format: :file)
34
- raise ArgumentError, "format must be :directory or :file" unless %i(file directory).include?(format)
34
+ raise ArgumentError, "format must be :directory or :file" unless [:file, :directory].include?(format)
35
+
35
36
  name = name.chomp('.')
36
37
  zone_file = "#{RecordStore.zones_path}/#{name}.yml"
37
38
  zone = { name => { config: config.to_hash } }
@@ -60,13 +61,17 @@ module RecordStore
60
61
  dir = File.dirname(filename)
61
62
  data = YAML.load_file(filename)
62
63
  raise 'more than one zone in file' if data.size > 1
64
+
63
65
  name, definition = data.first
64
66
  definition['records'] ||= []
65
67
  definition['records'] = definition['records'].map(&:deep_symbolize_keys)
66
68
  Dir["#{dir}/#{name}/*__*.yml"].each do |record_file|
67
69
  definition['records'] += load_yml_record_definitions(name, record_file)
68
70
  end
69
- Zone.new(name: name, records: definition['records'], config: definition['config'])
71
+
72
+ asts = { filename => Psych.parse_file(filename) }
73
+
74
+ Zone.new(name: name, records: definition['records'], config: definition['config'], abstract_syntax_trees: asts)
70
75
  end
71
76
 
72
77
  def load_yml_record_definitions(name, record_file)
@@ -20,6 +20,8 @@ module RecordStore
20
20
  validate :validate_provider_can_handle_zone_records
21
21
  validate :validate_no_empty_non_terminal
22
22
  validate :validate_can_handle_alias_records
23
+ validate :validate_no_duplicate_keys
24
+ validate :validate_zone_record_not_shadowed
23
25
 
24
26
  class << self
25
27
  def download(name, provider_name, **write_options)
@@ -31,7 +33,7 @@ module RecordStore
31
33
  zone.config = Zone::Config.new(
32
34
  providers: [provider_name],
33
35
  ignore_patterns: [{ type: "NS", fqdn: "#{name}." }],
34
- supports_alias: (zone.records.map(&:type).include?('ALIAS') || nil)
36
+ supports_alias: zone.records.map(&:type).include?('ALIAS') || nil,
35
37
  )
36
38
 
37
39
  zone.write(**write_options)
@@ -51,7 +53,7 @@ module RecordStore
51
53
  (ENV['RECORD_STORE_MAX_THREADS'] || DEFAULT_MAX_PARALLEL_THREADS).to_i
52
54
  end
53
55
 
54
- def modified(verbose: false) # rubocop:disable Lint/UnusedMethodArgument
56
+ def modified(verbose: false)
55
57
  modified_zones = []
56
58
  mutex = Mutex.new
57
59
  zones = all
@@ -61,7 +63,12 @@ module RecordStore
61
63
  current_zone = nil
62
64
  while zones.any?
63
65
  mutex.synchronize { current_zone = zones.shift }
64
- mutex.synchronize { modified_zones << current_zone } unless current_zone.unchanged?
66
+ break if current_zone.nil? # account for the race between `zones.any?` and `zones.shift`
67
+
68
+ # `unchanged?` is deliberately outside locked context since it's a bit CPU/time heavy
69
+ unless current_zone.unchanged?
70
+ mutex.synchronize { modified_zones << current_zone }
71
+ end
65
72
  end
66
73
  end
67
74
  end.each(&:join)
@@ -70,17 +77,16 @@ module RecordStore
70
77
  end
71
78
  end
72
79
 
73
- def initialize(name:, records: [], config: {})
80
+ def initialize(name:, records: [], config: {}, abstract_syntax_trees: {})
74
81
  @name = Record.ensure_ends_with_dot(name)
75
- @config = RecordStore::Zone::Config.new(config.deep_symbolize_keys)
82
+ @config = RecordStore::Zone::Config.new(**config.deep_symbolize_keys)
76
83
  @records = build_records(records)
84
+ @abstract_syntax_trees = abstract_syntax_trees
77
85
  end
78
86
 
79
87
  def build_changesets(all: false)
80
- @changesets ||= begin
81
- providers.map do |provider|
82
- Changeset.build_from(provider: provider, zone: self, all: all)
83
- end
88
+ @changesets ||= providers.map do |provider|
89
+ Changeset.build_from(provider: provider, zone: self, all: all)
84
90
  end
85
91
  end
86
92
 
@@ -138,24 +144,22 @@ module RecordStore
138
144
  authority = fetch_soa(nameserver) do |reply, _name|
139
145
  break if reply.answer.any?
140
146
 
141
- raise "No authority found (#{name})" unless reply.authority.any?
147
+ raise "No authority found (#{name})" if reply.authority.none?
142
148
 
143
149
  break extract_authority(reply.authority)
144
150
  end
145
151
 
146
152
  # candidate DNS name is returned instead when NXDomain or other error
147
- return nil if unrooted_name.casecmp?(Array(authority).first.to_s)
153
+ return if unrooted_name.casecmp?(Array(authority).first.to_s)
148
154
 
149
155
  authority
150
156
  end
151
157
 
152
158
  private
153
159
 
154
- def fetch_soa(nameserver)
160
+ def fetch_soa(nameserver, &block)
155
161
  Resolv::DNS.open(nameserver: nameserver) do |resolv|
156
- resolv.fetch_resource(name, Resolv::DNS::Resource::IN::SOA) do |reply, name|
157
- yield reply, name
158
- end
162
+ resolv.fetch_resource(name, Resolv::DNS::Resource::IN::SOA, &block)
159
163
  end
160
164
  end
161
165
 
@@ -168,6 +172,7 @@ module RecordStore
168
172
  rescue Errno::EHOSTUNREACH => e
169
173
  $stderr.puts "Warning: #{e} [host=#{nameserver}]"
170
174
  raise if nameservers.empty?
175
+
171
176
  retry
172
177
  end
173
178
  end
@@ -189,7 +194,13 @@ module RecordStore
189
194
  end
190
195
 
191
196
  def build_records(records)
192
- records.map { |record| Record.build_from_yaml_definition(record) }
197
+ all_records = records.map { |record| Record.build_from_yaml_definition(record) }
198
+
199
+ config.implicit_records_templates.each do |template|
200
+ all_records.push(*template.generate_records_to_inject(current_records: all_records))
201
+ end
202
+
203
+ all_records
193
204
  end
194
205
 
195
206
  def validate_records
@@ -232,6 +243,7 @@ module RecordStore
232
243
  cname_records.each do |cname_record|
233
244
  records.each do |record|
234
245
  next unless record.fqdn == cname_record.fqdn && record != cname_record
246
+
235
247
  case record.type
236
248
  when 'SIG', 'NXT', 'KEY'
237
249
  # this is fine
@@ -264,6 +276,27 @@ module RecordStore
264
276
  end
265
277
  end
266
278
 
279
+ def validate_zone_record_not_shadowed
280
+ nameserver_fqdns = records
281
+ .select { |record| record.is_a?(Record::NS) && name != record.fqdn }
282
+ .map { |record| record.fqdn.delete_suffix(".") }
283
+ .uniq
284
+
285
+ nameserver_fqdns.each do |ns_record|
286
+ selected_records = records.reject do |record|
287
+ record.is_a?(Record::NS) &&
288
+ record.fqdn.delete_suffix(".") == ns_record
289
+ end
290
+ selected_records.each do |record|
291
+ normalized_record = record.fqdn.delete_suffix(".")
292
+ next unless normalized_record.end_with?(".#{ns_record}") || normalized_record == ns_record
293
+
294
+ errors.add(:records, "Record #{record.fqdn} #{record.type} in Zone #{name} " \
295
+ "is shadowed by #{ns_record} and will be ignored")
296
+ end
297
+ end
298
+ end
299
+
267
300
  def validate_no_empty_non_terminal
268
301
  return unless config.empty_non_terminal_over_wildcard?
269
302
 
@@ -273,14 +306,14 @@ module RecordStore
273
306
 
274
307
  terminal_records = records.map(&:fqdn)
275
308
  .select { |record| record.match?(/^([a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_])#{Regexp.escape(suffix)}$/) }
276
- next unless terminal_records.any?
309
+ next if terminal_records.none?
277
310
 
278
311
  intermediate_records = records.map(&:fqdn)
279
312
  .select { |record| record.match?(/^([a-zA-Z0-9\-_]+)#{Regexp.escape(suffix)}$/) }
280
313
  terminal_records.each do |terminal_record|
281
314
  non_terminal = terminal_record.partition('.').last
282
315
  errors.add(:records, "found empty non-terminal #{non_terminal} "\
283
- "(caused by existing records #{wildcard} and #{terminal_record})")\
316
+ "(caused by existing records #{wildcard} and #{terminal_record})")\
284
317
  unless intermediate_records.include?(non_terminal)
285
318
  end
286
319
  end
@@ -301,5 +334,34 @@ module RecordStore
301
334
 
302
335
  errors.add(:records, "ALIAS record should be defined on the root of the zone: #{alias_record}")
303
336
  end
337
+
338
+ def validate_no_duplicate_keys
339
+ @abstract_syntax_trees.each do |filename, ast|
340
+ validate_no_duplicate_keys_in_node(filename, ast)
341
+ end
342
+ end
343
+
344
+ def validate_no_duplicate_keys_in_node(filename, node)
345
+ if node.mapping?
346
+ keys = node
347
+ .children
348
+ .each_slice(2)
349
+ .map(&:first)
350
+ .map(&:value)
351
+ .sort
352
+ dup_keys = keys
353
+ .find_all { |k| keys.count(k) > 1 }
354
+ .uniq
355
+ unless dup_keys.empty?
356
+ location = "#{File.basename(filename)}:#{node.start_line}"
357
+ description = "multiple definitions for keys #{dup_keys}"
358
+ errors.add(:records, "#{location}: #{description}")
359
+ end
360
+ end
361
+
362
+ node.children&.each do |child|
363
+ validate_no_duplicate_keys_in_node(filename, child)
364
+ end
365
+ end
304
366
  end
305
367
  end
data/lib/record_store.rb CHANGED
@@ -28,6 +28,7 @@ require 'record_store/zone/yaml_definitions'
28
28
  require 'record_store/zone'
29
29
  require 'record_store/zone/config'
30
30
  require 'record_store/zone/config/ignore_pattern'
31
+ require 'record_store/zone/config/implicit_record_template'
31
32
  require 'record_store/changeset'
32
33
  require 'record_store/provider'
33
34
  require 'record_store/provider/dynect'
@@ -43,6 +44,7 @@ module RecordStore
43
44
  class << self
44
45
  attr_writer :secrets_path
45
46
  attr_writer :zones_path
47
+ attr_writer :implicit_records_templates_path
46
48
 
47
49
  def secrets_path
48
50
  @secrets_path ||= File.expand_path(config.fetch('secrets_path'), File.dirname(config_path))
@@ -50,8 +52,10 @@ module RecordStore
50
52
 
51
53
  def zones_path
52
54
  @zones_path ||= Pathname.new(
53
- File.expand_path(config.fetch('zones_path'),
54
- File.dirname(config_path)),
55
+ File.expand_path(
56
+ config.fetch('zones_path'),
57
+ File.dirname(config_path),
58
+ ),
55
59
  ).realpath.to_s
56
60
  end
57
61
 
@@ -59,6 +63,15 @@ module RecordStore
59
63
  @config_path ||= File.expand_path('config.yml', Dir.pwd)
60
64
  end
61
65
 
66
+ def implicit_records_templates_path
67
+ @implicit_records_templates_path ||= Pathname.new(
68
+ File.expand_path(
69
+ config.fetch('implicit_records_templates_path'),
70
+ File.dirname(config_path),
71
+ ),
72
+ ).realpath.to_s
73
+ end
74
+
62
75
  def config_path=(config_path)
63
76
  @config = @zones_path = @secrets_path = nil
64
77
  @config_path = config_path