zonesync 0.9.0 → 0.11.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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +90 -0
  3. data/Gemfile +1 -0
  4. data/lib/zonesync/cli.rb +9 -0
  5. data/lib/zonesync/cloudflare.rb +64 -39
  6. data/lib/zonesync/diff.rb +10 -1
  7. data/lib/zonesync/errors.rb +35 -0
  8. data/lib/zonesync/generate.rb +14 -0
  9. data/lib/zonesync/http.rb +24 -8
  10. data/lib/zonesync/logger.rb +20 -15
  11. data/lib/zonesync/manifest.rb +68 -21
  12. data/lib/zonesync/parser.rb +337 -0
  13. data/lib/zonesync/provider.rb +105 -26
  14. data/lib/zonesync/record.rb +18 -23
  15. data/lib/zonesync/record_hash.rb +15 -0
  16. data/lib/zonesync/route53.rb +146 -28
  17. data/lib/zonesync/sync.rb +51 -0
  18. data/lib/zonesync/validator.rb +77 -19
  19. data/lib/zonesync/version.rb +1 -1
  20. data/lib/zonesync/zonefile.rb +22 -311
  21. data/lib/zonesync.rb +28 -60
  22. data/sorbet/config +4 -0
  23. data/sorbet/rbi/annotations/.gitattributes +1 -0
  24. data/sorbet/rbi/annotations/activesupport.rbi +457 -0
  25. data/sorbet/rbi/annotations/minitest.rbi +119 -0
  26. data/sorbet/rbi/annotations/webmock.rbi +9 -0
  27. data/sorbet/rbi/gems/.gitattributes +1 -0
  28. data/sorbet/rbi/gems/activesupport@8.0.1.rbi +18474 -0
  29. data/sorbet/rbi/gems/addressable@2.8.7.rbi +1994 -0
  30. data/sorbet/rbi/gems/base64@0.2.0.rbi +507 -0
  31. data/sorbet/rbi/gems/benchmark@0.4.0.rbi +618 -0
  32. data/sorbet/rbi/gems/bigdecimal@3.1.9.rbi +9 -0
  33. data/sorbet/rbi/gems/concurrent-ruby@1.3.4.rbi +11645 -0
  34. data/sorbet/rbi/gems/connection_pool@2.4.1.rbi +9 -0
  35. data/sorbet/rbi/gems/crack@1.0.0.rbi +145 -0
  36. data/sorbet/rbi/gems/date@3.4.1.rbi +75 -0
  37. data/sorbet/rbi/gems/diff-lcs@1.5.1.rbi +1131 -0
  38. data/sorbet/rbi/gems/drb@2.2.1.rbi +1347 -0
  39. data/sorbet/rbi/gems/erubi@1.13.1.rbi +155 -0
  40. data/sorbet/rbi/gems/hashdiff@1.1.2.rbi +353 -0
  41. data/sorbet/rbi/gems/i18n@1.14.6.rbi +2275 -0
  42. data/sorbet/rbi/gems/io-console@0.8.0.rbi +9 -0
  43. data/sorbet/rbi/gems/logger@1.6.4.rbi +940 -0
  44. data/sorbet/rbi/gems/minitest@5.25.4.rbi +1547 -0
  45. data/sorbet/rbi/gems/netrc@0.11.0.rbi +159 -0
  46. data/sorbet/rbi/gems/parallel@1.26.3.rbi +291 -0
  47. data/sorbet/rbi/gems/polyglot@0.3.5.rbi +42 -0
  48. data/sorbet/rbi/gems/prism@1.3.0.rbi +40040 -0
  49. data/sorbet/rbi/gems/psych@5.2.2.rbi +1785 -0
  50. data/sorbet/rbi/gems/public_suffix@6.0.1.rbi +936 -0
  51. data/sorbet/rbi/gems/rake@13.2.1.rbi +3028 -0
  52. data/sorbet/rbi/gems/rbi@0.2.2.rbi +4527 -0
  53. data/sorbet/rbi/gems/rdoc@6.10.0.rbi +12766 -0
  54. data/sorbet/rbi/gems/reline@0.6.0.rbi +9 -0
  55. data/sorbet/rbi/gems/rexml@3.4.0.rbi +4974 -0
  56. data/sorbet/rbi/gems/rspec-core@3.13.2.rbi +10896 -0
  57. data/sorbet/rbi/gems/rspec-expectations@3.13.3.rbi +8183 -0
  58. data/sorbet/rbi/gems/rspec-mocks@3.13.2.rbi +5341 -0
  59. data/sorbet/rbi/gems/rspec-support@3.13.2.rbi +1630 -0
  60. data/sorbet/rbi/gems/rspec@3.13.0.rbi +83 -0
  61. data/sorbet/rbi/gems/securerandom@0.4.1.rbi +75 -0
  62. data/sorbet/rbi/gems/spoom@1.5.0.rbi +4932 -0
  63. data/sorbet/rbi/gems/stringio@3.1.2.rbi +9 -0
  64. data/sorbet/rbi/gems/tapioca@0.16.6.rbi +3611 -0
  65. data/sorbet/rbi/gems/thor@1.3.2.rbi +4378 -0
  66. data/sorbet/rbi/gems/treetop@1.6.12.rbi +1895 -0
  67. data/sorbet/rbi/gems/tzinfo@2.0.6.rbi +5918 -0
  68. data/sorbet/rbi/gems/uri@1.0.2.rbi +2340 -0
  69. data/sorbet/rbi/gems/webmock@3.24.0.rbi +1780 -0
  70. data/sorbet/rbi/gems/yard-sorbet@0.9.0.rbi +435 -0
  71. data/sorbet/rbi/gems/yard@0.9.37.rbi +18379 -0
  72. data/sorbet/rbi/todo.rbi +7 -0
  73. data/sorbet/tapioca/config.yml +13 -0
  74. data/sorbet/tapioca/require.rb +4 -0
  75. data/zonesync.gemspec +3 -0
  76. metadata +102 -2
@@ -1,33 +1,37 @@
1
+ # typed: strict
2
+ require "sorbet-runtime"
3
+
1
4
  require "zonesync/record"
5
+ require "zonesync/record_hash"
2
6
  require "digest"
3
7
 
4
8
  module Zonesync
5
- class Manifest < Struct.new(:records, :zone)
9
+ Manifest = Struct.new(:records, :zone) do
10
+ extend T::Sig
6
11
  DIFFABLE_RECORD_TYPES =
7
- %w[A AAAA CNAME MX TXT SPF NAPTR PTR].sort
12
+ T.let(%w[A AAAA CNAME MX TXT SPF NAPTR PTR].sort, T::Array[String])
8
13
 
14
+ sig { returns(T.nilable(Zonesync::Record)) }
9
15
  def existing
10
16
  records.find(&:manifest?)
11
17
  end
12
18
 
19
+ sig { returns(T::Boolean) }
13
20
  def existing?
14
21
  !!existing
15
22
  end
16
23
 
24
+ sig { returns(Zonesync::Record) }
17
25
  def generate
18
- Record.new(
19
- name: "zonesync_manifest.#{zone.origin}",
20
- type: "TXT",
21
- ttl: 3600,
22
- rdata: generate_rdata,
23
- comment: nil,
24
- )
26
+ generate_v2
25
27
  end
26
28
 
29
+ sig { returns(T.nilable(Zonesync::Record)) }
27
30
  def existing_checksum
28
31
  records.find(&:checksum?)
29
32
  end
30
33
 
34
+ sig { returns(Zonesync::Record) }
31
35
  def generate_checksum
32
36
  input_string = diffable_records.map(&:to_s).join
33
37
  sha256 = Digest::SHA256.hexdigest(input_string)
@@ -40,6 +44,19 @@ module Zonesync
40
44
  )
41
45
  end
42
46
 
47
+ sig { returns(Zonesync::Record) }
48
+ def generate_v2
49
+ hashes = diffable_records.map { |record| RecordHash.generate(record) }
50
+ Record.new(
51
+ name: "zonesync_manifest.#{zone.origin}",
52
+ type: "TXT",
53
+ ttl: 3600,
54
+ rdata: hashes.join(',').inspect,
55
+ comment: nil,
56
+ )
57
+ end
58
+
59
+ sig { params(record: Zonesync::Record).returns(T::Boolean) }
43
60
  def diffable? record
44
61
  if existing?
45
62
  matches?(record)
@@ -48,20 +65,46 @@ module Zonesync
48
65
  end
49
66
  end
50
67
 
68
+ sig { returns(T::Boolean) }
69
+ def v1_format?
70
+ return false unless existing?
71
+ manifest_data = T.must(existing).rdata[1..-2]
72
+ # V1 format uses "TYPE:" syntax, v2 uses comma-separated hashes
73
+ manifest_data.include?(":") || manifest_data.include?(";")
74
+ end
75
+
76
+ sig { returns(T::Boolean) }
77
+ def v2_format?
78
+ return false unless existing?
79
+ !v1_format?
80
+ end
81
+
82
+ sig { params(record: Zonesync::Record).returns(T::Boolean) }
51
83
  def matches? record
52
84
  return false unless existing?
53
- hash = existing
54
- .rdata[1..-2] # remove quotes
55
- .split(";")
56
- .reduce({}) do |hash, pair|
57
- type, short_names = pair.split(":")
58
- hash[type] = short_names.split(",")
59
- hash
60
- end
61
- shorthands = hash.fetch(record.type, [])
62
- shorthands.include?(shorthand_for(record))
85
+ manifest_data = T.must(existing).rdata[1..-2] # remove quotes
86
+
87
+ # Check if this is v2 format (comma-separated hashes) or v1 format (type:names)
88
+ if manifest_data.include?(";")
89
+ # V1 format: "A:@,mail;CNAME:www;MX:@ 10,@ 20"
90
+ hash = manifest_data
91
+ .split(";")
92
+ .reduce({}) do |hash, pair|
93
+ type, short_names = pair.split(":")
94
+ hash[type] = short_names.split(",")
95
+ hash
96
+ end
97
+ shorthands = hash.fetch(record.type, [])
98
+ shorthands.include?(shorthand_for(record))
99
+ else
100
+ # V2 format: "1r81el0,60oib3,ky0g92,9pp0kg"
101
+ expected_hashes = manifest_data.split(",")
102
+ record_hash = RecordHash.generate(record)
103
+ expected_hashes.include?(record_hash)
104
+ end
63
105
  end
64
106
 
107
+ sig { params(record: Zonesync::Record, with_type: T::Boolean).returns(String) }
65
108
  def shorthand_for record, with_type: false
66
109
  shorthand = record.short_name(zone.origin)
67
110
  shorthand = "#{record.type}:#{shorthand}" if with_type
@@ -73,25 +116,29 @@ module Zonesync
73
116
 
74
117
  private
75
118
 
119
+ sig { returns(String) }
76
120
  def generate_rdata
77
121
  generate_manifest.map do |type, short_names|
78
122
  "#{type}:#{short_names.join(",")}"
79
123
  end.join(";").inspect
80
124
  end
81
125
 
126
+ sig { returns(T::Array[Zonesync::Record]) }
82
127
  def diffable_records
83
128
  records.select do |record|
84
129
  diffable?(record)
85
130
  end.sort
86
131
  end
87
132
 
133
+ sig { returns(T::Hash[String, T::Array[String]]) }
88
134
  def generate_manifest
89
- diffable_records.reduce({}) do |hash, record|
135
+ hash = diffable_records.reduce({}) do |hash, record|
90
136
  hash[record.type] ||= []
91
137
  hash[record.type] << shorthand_for(record)
92
138
  hash[record.type].sort!
93
139
  hash
94
- end.sort_by(&:first)
140
+ end
141
+ Hash[hash.sort_by(&:first)]
95
142
  end
96
143
  end
97
144
  end
@@ -0,0 +1,337 @@
1
+ # typed: true
2
+ require "sorbet-runtime"
3
+
4
+ require "treetop"
5
+ Treetop.load File.join(__dir__, "zonefile")
6
+
7
+ module Zonesync
8
+ class Parser
9
+ def self.parse(zone_string)
10
+ parser = T.unsafe(ZonefileParser).new
11
+ result = parser.parse(zone_string)
12
+ if !result
13
+ puts zone_string
14
+ raise ParsingError, parser.failure_reason
15
+ end
16
+ origin = result.variables["ORIGIN"]
17
+ Zone.new(result.entries, origin: origin)
18
+ end
19
+
20
+ class ParsingError < RuntimeError; end
21
+ class UnknownRecordType < RuntimeError; end
22
+
23
+ class Zone
24
+ attr_reader :origin
25
+
26
+ attr_reader :records
27
+
28
+ def initialize(entries, origin: nil, alternate_origin: ".")
29
+ @origin = origin
30
+ @records = []
31
+ @vars = {"origin" => alternate_origin, :last_host => "."}
32
+ entries.each do |e|
33
+ case e.parse_type
34
+ when :variable
35
+ key = e.name.text_value.downcase
36
+ @vars[key] = case key
37
+ when "ttl"
38
+ e.value.text_value.to_i
39
+ else
40
+ e.value.text_value
41
+ end
42
+ when :soa
43
+ @records << SOA.new(@vars, e)
44
+ when :record
45
+ case e.record_type
46
+ when "A" then @records << A.new(@vars, e)
47
+ when "AAAA" then @records << AAAA.new(@vars, e)
48
+ when "CAA" then @records << CAA.new(@vars, e)
49
+ when "CNAME" then @records << CNAME.new(@vars, e)
50
+ when "MX" then @records << MX.new(@vars, e)
51
+ when "NAPTR" then @records << NAPTR.new(@vars, e)
52
+ when "NS" then @records << NS.new(@vars, e)
53
+ when "PTR" then @records << PTR.new(@vars, e)
54
+ when "SRV" then @records << SRV.new(@vars, e)
55
+ when "SPF" then @records << SPF.new(@vars, e)
56
+ when "SSHFP" then @records << SSHFP.new(@vars, e)
57
+ when "TXT" then @records << TXT.new(@vars, e)
58
+ when "SOA" then
59
+ # No-op
60
+ else
61
+ raise UnknownRecordType, "Unknown record type: #{e.record_type}"
62
+ end
63
+ end
64
+ end
65
+ @origin ||= soa.origin
66
+ end
67
+
68
+ def soa
69
+ records_of(SOA).first
70
+ end
71
+
72
+ def records_of(kl)
73
+ @records.select { |r| r.instance_of? kl }
74
+ end
75
+ end
76
+
77
+ class Record
78
+ attr_reader :ttl
79
+ def ttl= val
80
+ # handling for global TTL
81
+ @ttl = val || @vars["ttl"]
82
+ end
83
+
84
+ def klass
85
+ @klass = nil if @klass == ""
86
+ @klass ||= "IN"
87
+ end
88
+ attr_writer :klass
89
+
90
+ attr_accessor :comment, :host
91
+
92
+ def type
93
+ T.must(self.class.name).split("::").last
94
+ end
95
+
96
+ attr_reader :rdata
97
+
98
+ private
99
+
100
+ def qualify_host(host)
101
+ origin = vars["origin"]
102
+ host = vars[:last_host] if /^\s*$/.match?(host)
103
+ host = host.gsub(/@/, origin)
104
+ if /\.$/.match?(host)
105
+ host
106
+ elsif /^\./.match?(origin)
107
+ host + origin
108
+ else
109
+ host + "." + origin
110
+ end
111
+ end
112
+ attr_accessor :vars
113
+ end
114
+
115
+ class SOA < Record
116
+ attr_accessor :origin, :nameserver, :responsible_party, :serial, :refresh_time, :retry_time, :expiry_time, :nxttl
117
+
118
+ def initialize(vars, zonefile_soa = nil)
119
+ @vars = vars
120
+ if zonefile_soa
121
+ self.origin = qualify_host(zonefile_soa.origin.to_s)
122
+ @vars[:last_host] = origin
123
+ self.ttl = zonefile_soa.ttl.to_i
124
+ self.klass = zonefile_soa.klass.to_s
125
+ self.nameserver = qualify_host(zonefile_soa.ns.to_s)
126
+ self.responsible_party = qualify_host(zonefile_soa.rp.to_s)
127
+ self.serial = zonefile_soa.serial.to_i
128
+ self.refresh_time = zonefile_soa.refresh.to_i
129
+ self.retry_time = zonefile_soa.reretry.to_i
130
+ self.expiry_time = zonefile_soa.expiry.to_i
131
+ self.nxttl = zonefile_soa.nxttl.to_i
132
+ end
133
+ end
134
+
135
+ alias host origin
136
+ end
137
+
138
+ class A < Record
139
+ attr_accessor :address
140
+
141
+ def initialize(vars, zonefile_record)
142
+ @vars = vars
143
+ if zonefile_record
144
+ self.host = qualify_host(zonefile_record.host.to_s)
145
+ @vars[:last_host] = host
146
+ self.ttl = zonefile_record.ttl.to_i
147
+ self.klass = zonefile_record.klass.to_s
148
+ self.address = zonefile_record.ip_address.to_s
149
+ self.comment = zonefile_record.comment&.to_s
150
+ end
151
+ end
152
+
153
+ alias rdata address
154
+ end
155
+
156
+ class AAAA < A
157
+ end
158
+
159
+ class CAA < Record
160
+ attr_accessor :flags, :tag, :value
161
+
162
+ def initialize(vars, zonefile_record)
163
+ @vars = vars
164
+ if zonefile_record
165
+ self.host = qualify_host(zonefile_record.host.to_s)
166
+ @vars[:last_host] = host
167
+ self.ttl = zonefile_record.ttl.to_i
168
+ self.klass = zonefile_record.klass.to_s
169
+ self.flags = zonefile_record.flags.to_i
170
+ self.tag = zonefile_record.tag.to_s
171
+ self.value = zonefile_record.value.to_s
172
+ self.comment = zonefile_record.comment&.to_s
173
+ end
174
+ end
175
+ end
176
+
177
+ class CNAME < Record
178
+ attr_accessor :domainname
179
+
180
+ def initialize(vars, zonefile_record)
181
+ @vars = vars
182
+ if zonefile_record
183
+ self.host = qualify_host(zonefile_record.host.to_s)
184
+ @vars[:last_host] = host
185
+ self.ttl = zonefile_record.ttl.to_i
186
+ self.klass = zonefile_record.klass.to_s
187
+ self.domainname = qualify_host(zonefile_record.target.to_s)
188
+ self.comment = zonefile_record.comment&.to_s
189
+ end
190
+ end
191
+
192
+ alias target domainname
193
+ alias rdata domainname
194
+ alias alias domainname
195
+ end
196
+
197
+ class MX < Record
198
+ attr_accessor :priority, :domainname
199
+
200
+ def initialize(vars, zonefile_record)
201
+ @vars = vars
202
+ if zonefile_record
203
+ self.host = qualify_host(zonefile_record.host.to_s)
204
+ @vars[:last_host] = host
205
+ self.ttl = zonefile_record.ttl.to_i
206
+ self.klass = zonefile_record.klass.to_s
207
+ self.priority = zonefile_record.priority.to_i
208
+ self.domainname = qualify_host(zonefile_record.exchanger.to_s)
209
+ self.comment = zonefile_record.comment&.to_s
210
+ end
211
+ end
212
+
213
+ def rdata
214
+ "#{priority} #{domainname}"
215
+ end
216
+
217
+ alias exchange domainname
218
+ alias exchanger domainname
219
+ end
220
+
221
+ class NAPTR < Record
222
+ attr_accessor :data
223
+
224
+ def initialize(vars, zonefile_record)
225
+ @vars = vars
226
+ if zonefile_record
227
+ self.host = qualify_host(zonefile_record.host.to_s)
228
+ @vars[:last_host] = host
229
+ self.ttl = zonefile_record.ttl.to_i
230
+ self.klass = zonefile_record.klass.to_s
231
+ self.data = zonefile_record.data.to_s
232
+ self.comment = zonefile_record.comment&.to_s
233
+ end
234
+ end
235
+
236
+ alias rdata data
237
+ end
238
+
239
+ class NS < Record
240
+ attr_accessor :domainname
241
+
242
+ def initialize(vars, zonefile_record)
243
+ @vars = vars
244
+ if zonefile_record
245
+ self.host = qualify_host(zonefile_record.host.to_s)
246
+ @vars[:last_host] = host
247
+ self.ttl = zonefile_record.ttl.to_i
248
+ self.klass = zonefile_record.klass.to_s
249
+ self.domainname = qualify_host(zonefile_record.nameserver.to_s)
250
+ self.comment = zonefile_record.comment&.to_s
251
+ end
252
+ end
253
+
254
+ alias nameserver domainname
255
+ alias rdata domainname
256
+ end
257
+
258
+ class PTR < Record
259
+ attr_accessor :domainname
260
+
261
+ def initialize(vars, zonefile_record)
262
+ @vars = vars
263
+ if zonefile_record
264
+ self.host = qualify_host(zonefile_record.host.to_s)
265
+ @vars[:last_host] = host
266
+ self.ttl = zonefile_record.ttl.to_i
267
+ self.klass = zonefile_record.klass.to_s
268
+ self.domainname = qualify_host(zonefile_record.target.to_s)
269
+ self.comment = zonefile_record.comment&.to_s
270
+ end
271
+ end
272
+
273
+ alias target domainname
274
+ alias rdata domainname
275
+ end
276
+
277
+ class SRV < Record
278
+ attr_accessor :priority, :weight, :port, :domainname
279
+
280
+ def initialize(vars, zonefile_record)
281
+ @vars = vars
282
+ if zonefile_record
283
+ self.host = qualify_host(zonefile_record.host.to_s)
284
+ @vars[:last_host] = host
285
+ self.ttl = zonefile_record.ttl.to_i
286
+ self.klass = zonefile_record.klass.to_s
287
+ self.priority = zonefile_record.priority.to_i
288
+ self.weight = zonefile_record.weight.to_i
289
+ self.port = zonefile_record.port.to_i
290
+ self.domainname = qualify_host(zonefile_record.target.to_s)
291
+ self.comment = zonefile_record.comment&.to_s
292
+ end
293
+ end
294
+
295
+ alias target domainname
296
+ end
297
+
298
+ class SSHFP < Record
299
+ attr_accessor :alg, :fptype, :fp
300
+
301
+ def initialize(vars, zonefile_record)
302
+ @vars = vars
303
+ if zonefile_record
304
+ self.host = qualify_host(zonefile_record.host.to_s)
305
+ @vars[:last_host] = host
306
+ self.ttl = zonefile_record.ttl.to_i
307
+ self.klass = zonefile_record.klass.to_s
308
+ self.alg = zonefile_record.alg.to_i
309
+ self.fptype = zonefile_record.fptype.to_i
310
+ self.fp = zonefile_record.fp.to_s
311
+ self.comment = zonefile_record.comment&.to_s
312
+ end
313
+ end
314
+ end
315
+
316
+ class TXT < Record
317
+ attr_accessor :data
318
+
319
+ def initialize(vars, zonefile_record)
320
+ @vars = vars
321
+ if zonefile_record
322
+ self.host = qualify_host(zonefile_record.host.to_s)
323
+ @vars[:last_host] = host
324
+ self.ttl = zonefile_record.ttl.to_i
325
+ self.klass = zonefile_record.klass.to_s
326
+ self.data = zonefile_record.data.to_s
327
+ self.comment = zonefile_record.comment&.to_s
328
+ end
329
+ end
330
+ alias rdata data
331
+ end
332
+
333
+ class SPF < TXT
334
+ end
335
+ end
336
+ end
337
+
@@ -1,58 +1,127 @@
1
+ # typed: strict
2
+ require "sorbet-runtime"
3
+
1
4
  require "zonesync/record"
2
5
  require "zonesync/zonefile"
3
6
  require "zonesync/manifest"
7
+ require "zonesync/diff"
8
+ require "zonesync/validator"
4
9
 
5
10
  module Zonesync
6
- class Provider < Struct.new(:credentials)
7
- def self.from credentials
8
- return credentials if credentials.is_a?(Provider)
9
- Zonesync.const_get(credentials[:provider]).new(credentials)
11
+ class Provider
12
+ extend T::Sig
13
+
14
+ sig { params(config: T::Hash[Symbol, String]).void }
15
+ def initialize config
16
+ @config = T.let(config, T::Hash[Symbol, String])
17
+ end
18
+ sig { returns(T::Hash[Symbol, String]) }
19
+ attr_reader :config
20
+
21
+ sig { params(config: T::Hash[Symbol, String]).returns(Provider) }
22
+ def self.from config
23
+ Zonesync.const_get(config.fetch(:provider)).new(config)
24
+ end
25
+
26
+ sig { params(other: Provider, force: T::Boolean).returns(T::Array[Operation]) }
27
+ def diff! other, force: false
28
+ operations = diff(other).call
29
+ Validator.call(operations, self, force: force)
30
+ operations
31
+ end
32
+
33
+ sig { params(other: Provider).returns(Diff) }
34
+ def diff other
35
+ Diff.new(
36
+ from: diffable_records,
37
+ to: other.diffable_records,
38
+ )
10
39
  end
11
40
 
41
+ sig { returns(T::Array[Record]) }
12
42
  def records
13
- zonefile.records.map do |record|
14
- Record.from_dns_zonefile_record(record)
15
- end
43
+ zonefile.records
16
44
  end
17
45
 
46
+ sig { returns(T::Array[Record]) }
18
47
  def diffable_records
19
48
  records.select do |record|
20
49
  manifest.diffable?(record)
21
50
  end.sort
22
51
  end
23
52
 
53
+ sig { returns(Manifest) }
24
54
  def manifest
25
- @manifest ||= Manifest.new(records, zonefile)
55
+ Manifest.new(records, zonefile)
26
56
  end
27
57
 
58
+ sig { returns(Zonefile) }
28
59
  private def zonefile
29
- @zonefile ||= begin
30
- body = read
31
- if body !~ /\sSOA\s/ # insert dummy SOA to trick parser if needed
32
- body.sub!(/\n([^$])/, "\n@ 1 SOA example.com example.com ( 2000010101 1 1 1 1 )\n\\1")
33
- end
34
- Zonefile.load(body)
35
- end
60
+ @zonefile ||= T.let(Zonefile.load(read), T.nilable(Zonefile))
36
61
  end
37
62
 
38
- def read record
39
- raise NotImplementedError
63
+ sig { returns(String) }
64
+ def read
65
+ Kernel.raise NotImplementedError
40
66
  end
41
67
 
42
- def write text
43
- raise NotImplementedError
68
+ sig { params(string: String).void }
69
+ def write string
70
+ Kernel.raise NotImplementedError
44
71
  end
45
72
 
73
+ sig { params(record: Record).void }
46
74
  def remove record
47
- raise NotImplementedError
75
+ Kernel.raise NotImplementedError
48
76
  end
49
77
 
78
+ sig { params(old_record: Record, new_record: Record).void }
50
79
  def change old_record, new_record
51
- raise NotImplementedError
80
+ Kernel.raise NotImplementedError
52
81
  end
53
82
 
83
+ sig { params(record: Record).void }
54
84
  def add record
55
- raise NotImplementedError
85
+ Kernel.raise NotImplementedError
86
+ end
87
+
88
+ # Helper method for graceful duplicate record handling
89
+ # Child classes can use this in their add method implementations
90
+ sig { params(record: Record, block: T.proc.void).void }
91
+ def add_with_duplicate_handling record, &block
92
+ begin
93
+ block.call
94
+ rescue DuplicateRecordError => e
95
+ # Gracefully handle duplicate records - this means the record
96
+ # already exists and we just want to start tracking it
97
+ puts "Record already exists in #{self.class.name}: #{e.record.name} #{e.record.type} - will start tracking it"
98
+ return
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ sig { params(remote_records: T::Array[Record], expected_hashes: T::Array[String]).returns(T::Array[Record]) }
105
+ def hash_based_diffable_records(remote_records, expected_hashes)
106
+ require 'set'
107
+ expected_set = Set.new(expected_hashes)
108
+ found_set = Set.new
109
+ diffable = []
110
+
111
+ remote_records.each do |record|
112
+ hash = RecordHash.generate(record)
113
+ if expected_set.include?(hash)
114
+ found_set.add(hash)
115
+ diffable << record
116
+ end
117
+ end
118
+
119
+ missing = expected_set - found_set
120
+ if missing.any?
121
+ raise ConflictError.new(nil, diffable.first || remote_records.first)
122
+ end
123
+
124
+ diffable.sort
56
125
  end
57
126
  end
58
127
 
@@ -60,22 +129,32 @@ module Zonesync
60
129
  require "zonesync/route53"
61
130
 
62
131
  class Memory < Provider
132
+ extend T::Sig
133
+
134
+ sig { returns(String) }
63
135
  def read
64
- credentials[:string]
136
+ config.fetch(:string)
65
137
  end
66
138
 
139
+ sig { params(string: String).void }
67
140
  def write string
68
- credentials[:string] = string
141
+ config[:string] = string
142
+ nil
69
143
  end
70
144
  end
71
145
 
72
146
  class Filesystem < Provider
147
+ extend T::Sig
148
+
149
+ sig { returns(String) }
73
150
  def read
74
- File.read(credentials[:path])
151
+ File.read(config.fetch(:path))
75
152
  end
76
153
 
154
+ sig { params(string: String).void }
77
155
  def write string
78
- File.write(credentials[:path], string)
156
+ File.write(config.fetch(:path), string)
157
+ nil
79
158
  end
80
159
  end
81
160
  end