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,32 +1,22 @@
1
+ # typed: strict
2
+ require "sorbet-runtime"
3
+
1
4
  module Zonesync
2
- class Record < Struct.new(:name, :type, :ttl, :rdata, :comment, keyword_init: true)
3
- def self.from_dns_zonefile_record record
4
- type = record.class.name.split("::").last
5
- rdata = case type
6
- when "SOA"
7
- def record.host = origin
8
- "" # it just gets ignored anyways
9
- when "A", "AAAA"
10
- record.address
11
- when "CNAME", "NS", "PTR"
12
- record.domainname
13
- when "MX"
14
- "#{record.priority} #{record.domainname}"
15
- when "TXT", "SPF", "NAPTR"
16
- record.data
17
- else
18
- raise NotImplementedError.new(record.class).to_s
19
- end
5
+ Record = Struct.new(:name, :type, :ttl, :rdata, :comment, keyword_init: true) do
6
+ extend T::Sig
20
7
 
8
+ sig { params(record: Zonesync::Parser::Record).returns(Record) }
9
+ def self.from_dns_zonefile_record record
21
10
  new(
22
11
  name: record.host,
23
- type:,
12
+ type: record.type,
24
13
  ttl: record.ttl,
25
- rdata:,
14
+ rdata: record.rdata,
26
15
  comment: record.comment,
27
16
  )
28
17
  end
29
18
 
19
+ sig { params(origin: String).returns(String) }
30
20
  def short_name origin
31
21
  ret = name.sub(origin, "")
32
22
  ret = ret.sub(/\.$/, "")
@@ -34,25 +24,30 @@ module Zonesync
34
24
  ret
35
25
  end
36
26
 
27
+ sig { returns(T::Boolean) }
37
28
  def manifest?
38
29
  type == "TXT" &&
39
- name =~ /^zonesync_manifest\./
30
+ name.match?(/^zonesync_manifest\./)
40
31
  end
41
32
 
33
+ sig { returns(T::Boolean) }
42
34
  def checksum?
43
35
  type == "TXT" &&
44
- name =~ /^zonesync_checksum\./
36
+ name.match?(/^zonesync_checksum\./)
45
37
  end
46
38
 
39
+ sig { params(other: Record).returns(Integer) }
47
40
  def <=> other
48
41
  to_sortable <=> other.to_sortable
49
42
  end
50
43
 
44
+ sig { returns([Integer, String, String, String, Integer]) }
51
45
  def to_sortable
52
46
  is_soa = type == "SOA" ? 0 : 1
53
- [is_soa, type, name, rdata, ttl]
47
+ [is_soa, type, name, rdata, ttl.to_i]
54
48
  end
55
49
 
50
+ sig { returns(String) }
56
51
  def to_s
57
52
  string = [name, ttl, type, rdata].join(" ")
58
53
  string << " ; #{comment}" if comment
@@ -0,0 +1,15 @@
1
+ # typed: strict
2
+ require "sorbet-runtime"
3
+ require "zlib"
4
+
5
+ module Zonesync
6
+ module RecordHash
7
+ extend T::Sig
8
+
9
+ sig { params(record: Record).returns(String) }
10
+ def self.generate(record)
11
+ identity = "#{record.name}:#{record.type}:#{record.ttl}:#{record.rdata}"
12
+ Zlib.crc32(identity).to_s(36)
13
+ end
14
+ end
15
+ end
@@ -1,34 +1,121 @@
1
+ # typed: strict
2
+ require "sorbet-runtime"
3
+
1
4
  require "zonesync/record"
2
5
  require "zonesync/http"
3
6
  require "rexml/document"
7
+ require "erb"
4
8
 
5
9
  module Zonesync
6
10
  class Route53 < Provider
11
+ sig { returns(String) }
7
12
  def read
8
- doc = REXML::Document.new(http.get(nil))
9
- records = doc.elements.collect("*/ResourceRecordSets/ResourceRecordSet") do |record_set|
10
- to_records(record_set)
11
- end.flatten.sort
12
- records.map(&:to_s).join("\n") + "\n"
13
+ @read = T.let(@read, T.nilable(String))
14
+ @read ||= begin
15
+ doc = REXML::Document.new(http.get(""))
16
+ records = doc.elements.collect("*/ResourceRecordSets/ResourceRecordSet") do |record_set|
17
+ to_records(record_set)
18
+ end.flatten.sort
19
+ records.map(&:to_s).join("\n") + "\n"
20
+ end
13
21
  end
14
22
 
23
+ sig { params(record: Record).void }
15
24
  def remove(record)
16
- change_record("DELETE", record)
25
+ if record.type == "TXT"
26
+ # Route53 requires all TXT records with the same name to be managed together
27
+ existing_txt_records = records.select do |r|
28
+ r.name == record.name && r.type == "TXT"
29
+ end
30
+
31
+ if existing_txt_records.length == 1
32
+ # Only one TXT record, delete it normally
33
+ change_record("DELETE", record)
34
+ else
35
+ # Multiple TXT records - delete all, then recreate without the removed one
36
+ remaining_txt_records = existing_txt_records.reject { |r| r == record }
37
+
38
+ # Use change_records to handle both DELETE and CREATE in one request
39
+ grouped = [
40
+ [existing_txt_records, "DELETE"],
41
+ [remaining_txt_records, "CREATE"]
42
+ ]
43
+
44
+ http.post("", ERB.new(<<~XML, trim_mode: "-").result(binding))
45
+ <?xml version="1.0" encoding="UTF-8"?>
46
+ <ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
47
+ <ChangeBatch>
48
+ <Changes>
49
+ <%- grouped.each do |records_list, action| -%>
50
+ <%- records_grouped = records_list.group_by { |r| [r.name, r.type, r.ttl] } -%>
51
+ <%- records_grouped.each do |(name, type, ttl), group_records| -%>
52
+ <Change>
53
+ <Action><%= action %></Action>
54
+ <ResourceRecordSet>
55
+ <Name><%= name %></Name>
56
+ <Type><%= type %></Type>
57
+ <TTL><%= ttl %></TTL>
58
+ <ResourceRecords>
59
+ <%- group_records.each do |group_record| -%>
60
+ <ResourceRecord>
61
+ <Value><%= group_record.rdata %></Value>
62
+ </ResourceRecord>
63
+ <%- end -%>
64
+ </ResourceRecords>
65
+ </ResourceRecordSet>
66
+ </Change>
67
+ <%- end -%>
68
+ <%- end -%>
69
+ </Changes>
70
+ </ChangeBatch>
71
+ </ChangeResourceRecordSetsRequest>
72
+ XML
73
+ end
74
+ else
75
+ change_record("DELETE", record)
76
+ end
17
77
  end
18
78
 
79
+ sig { params(old_record: Record, new_record: Record).void }
19
80
  def change(old_record, new_record)
20
81
  remove(old_record)
21
82
  add(new_record)
22
83
  end
23
84
 
85
+ sig { params(record: Record).void }
24
86
  def add(record)
25
- change_record("CREATE", record)
87
+ add_with_duplicate_handling(record) do
88
+ begin
89
+ if record.type == "TXT"
90
+ # Route53 requires all TXT records with the same name to be combined into a single record set
91
+ existing_txt_records = records.select do |r|
92
+ r.name == record.name && r.type == "TXT"
93
+ end
94
+ all_txt_records = existing_txt_records + [record]
95
+
96
+ # Use UPSERT if records already exist, CREATE if they don't
97
+ action = existing_txt_records.empty? ? "CREATE" : "UPSERT"
98
+ change_records(action, all_txt_records)
99
+ else
100
+ change_record("CREATE", record)
101
+ end
102
+ rescue RuntimeError => e
103
+ # Convert Route53-specific duplicate error to standard exception
104
+ if e.message.include?("RRSet already exists")
105
+ raise DuplicateRecordError.new(record, "Route53 duplicate record error")
106
+ else
107
+ # Re-raise other errors
108
+ raise
109
+ end
110
+ end
111
+ end
26
112
  end
27
113
 
28
114
  private
29
115
 
116
+ sig { params(action: String, record: Record).void }
30
117
  def change_record(action, record)
31
- http.post(nil, <<~XML)
118
+ http.post("", <<~XML)
32
119
  <?xml version="1.0" encoding="UTF-8"?>
33
120
  <ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
34
121
  <ChangeBatch>
@@ -36,12 +123,12 @@ module Zonesync
36
123
  <Change>
37
124
  <Action>#{action}</Action>
38
125
  <ResourceRecordSet>
39
- <Name>#{record[:name]}</Name>
40
- <Type>#{record[:type]}</Type>
41
- <TTL>#{record[:ttl]}</TTL>
126
+ <Name>#{record.name}</Name>
127
+ <Type>#{record.type}</Type>
128
+ <TTL>#{record.ttl}</TTL>
42
129
  <ResourceRecords>
43
130
  <ResourceRecord>
44
- <Value>#{record[:rdata]}</Value>
131
+ <Value>#{record.rdata}</Value>
45
132
  </ResourceRecord>
46
133
  </ResourceRecords>
47
134
  </ResourceRecordSet>
@@ -52,50 +139,80 @@ module Zonesync
52
139
  XML
53
140
  end
54
141
 
142
+ sig { params(action: String, records_list: T::Array[Record]).void }
143
+ def change_records(action, records_list)
144
+ # Group records by name and type to handle multiple values
145
+ grouped = records_list.group_by { |r| [r.name, r.type, r.ttl] }
146
+
147
+ http.post("", ERB.new(<<~XML, trim_mode: "-").result(binding))
148
+ <?xml version="1.0" encoding="UTF-8"?>
149
+ <ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
150
+ <ChangeBatch>
151
+ <Changes>
152
+ <%- grouped.each do |(name, type, ttl), records| -%>
153
+ <Change>
154
+ <Action><%= action %></Action>
155
+ <ResourceRecordSet>
156
+ <Name><%= name %></Name>
157
+ <Type><%= type %></Type>
158
+ <TTL><%= ttl %></TTL>
159
+ <ResourceRecords>
160
+ <%- records.each do |record| -%>
161
+ <ResourceRecord>
162
+ <Value><%= record.rdata %></Value>
163
+ </ResourceRecord>
164
+ <%- end -%>
165
+ </ResourceRecords>
166
+ </ResourceRecordSet>
167
+ </Change>
168
+ <%- end -%>
169
+ </Changes>
170
+ </ChangeBatch>
171
+ </ChangeResourceRecordSetsRequest>
172
+ XML
173
+ end
174
+
175
+ sig { params(el: REXML::Element).returns(T::Array[Record]) }
55
176
  def to_records(el)
56
177
  el.elements.collect("ResourceRecords/ResourceRecord") do |rr|
57
178
  name = normalize_trailing_period(get_value(el, "Name"))
58
179
  type = get_value(el, "Type")
180
+ ttl = get_value(el, "TTL")
59
181
  rdata = get_value(rr, "Value")
60
182
 
61
183
  record = Record.new(
62
184
  name:,
63
185
  type:,
64
- ttl: get_value(el, "TTL"),
186
+ ttl:,
65
187
  rdata:,
66
188
  comment: nil, # Route 53 does not have a direct comment field
67
189
  )
68
190
  end
69
191
  end
70
192
 
193
+ sig { params(el: REXML::Element, field: String).returns(String) }
71
194
  def get_value el, field
72
195
  el.elements[field].text.gsub(/\\(\d{3})/) { $1.to_i(8).chr } # unescape octal
73
196
  end
74
197
 
75
- def from_record(record)
76
- {
77
- Name: normalize_trailing_period(record[:name]),
78
- Type: record[:type],
79
- TTL: record[:ttl],
80
- ResourceRecords: record[:rdata].split(",").map { |value| { Value: value } }
81
- }
82
- end
83
-
198
+ sig { params(value: String).returns(String) }
84
199
  def normalize_trailing_period(value)
85
200
  value =~ /\.$/ ? value : value + "."
86
201
  end
87
202
 
203
+ sig { returns(HTTP) }
88
204
  def http
89
205
  return @http if @http
90
- @http = HTTP.new("https://route53.amazonaws.com/2013-04-01/hostedzone/#{credentials.fetch(:hosted_zone_id)}/rrset")
91
- @http.before_request do |request, uri, body|
206
+ @http = T.let(HTTP.new("https://route53.amazonaws.com/2013-04-01/hostedzone/#{config.fetch(:hosted_zone_id)}/rrset"), T.nilable(Zonesync::HTTP))
207
+ T.must(@http).before_request do |request, uri, body|
92
208
  request["Content-Type"] = "application/xml"
93
209
  request["X-Amz-Date"] = Time.now.utc.strftime("%Y%m%dT%H%M%SZ")
94
210
  request["Authorization"] = sign_request(request.method, uri, body)
95
211
  end
96
- @http
212
+ T.must(@http)
97
213
  end
98
214
 
215
+ sig { params(method: String, uri: URI::HTTPS, body: T.nilable(String)).returns(String) }
99
216
  def sign_request(method, uri, body)
100
217
  service = "route53"
101
218
  date = Time.now.utc.strftime("%Y%m%d")
@@ -115,7 +232,7 @@ module Zonesync
115
232
  ].join("\n")
116
233
 
117
234
  algorithm = "AWS4-HMAC-SHA256"
118
- credential_scope = "#{date}/#{credentials.fetch(:aws_region)}/#{service}/aws4_request"
235
+ credential_scope = "#{date}/#{config.fetch(:aws_region)}/#{service}/aws4_request"
119
236
  string_to_sign = [
120
237
  algorithm,
121
238
  amz_date,
@@ -123,12 +240,13 @@ module Zonesync
123
240
  OpenSSL::Digest::SHA256.hexdigest(canonical_request)
124
241
  ].join("\n")
125
242
 
126
- signing_key = get_signature_key(credentials.fetch(:aws_secret_access_key), date, credentials.fetch(:aws_region), service)
243
+ signing_key = get_signature_key(config.fetch(:aws_secret_access_key), date, config.fetch(:aws_region), service)
127
244
  signature = OpenSSL::HMAC.hexdigest("SHA256", signing_key, string_to_sign)
128
245
 
129
- "#{algorithm} Credential=#{credentials.fetch(:aws_access_key_id)}/#{credential_scope}, SignedHeaders=#{signed_headers}, Signature=#{signature}"
246
+ "#{algorithm} Credential=#{config.fetch(:aws_access_key_id)}/#{credential_scope}, SignedHeaders=#{signed_headers}, Signature=#{signature}"
130
247
  end
131
248
 
249
+ sig { params(key: String, date_stamp: String, region_name: String, service_name: String).returns(String) }
132
250
  def get_signature_key(key, date_stamp, region_name, service_name)
133
251
  k_date = OpenSSL::HMAC.digest("SHA256", "AWS4" + key, date_stamp)
134
252
  k_region = OpenSSL::HMAC.digest("SHA256", k_date, region_name)
@@ -0,0 +1,51 @@
1
+ # typed: strict
2
+ require "sorbet-runtime"
3
+
4
+ require "zonesync/logger"
5
+
6
+ module Zonesync
7
+ Sync = Struct.new(:source, :destination) do
8
+ extend T::Sig
9
+
10
+ sig { params(dry_run: T::Boolean, force: T::Boolean).void }
11
+ def call dry_run: false, force: false
12
+ operations = destination.diff!(source, force: force)
13
+
14
+ smanifest = source.manifest.generate
15
+ dmanifest = destination.manifest.existing
16
+ if smanifest != dmanifest
17
+ if dmanifest
18
+ operations << [:change, [dmanifest, smanifest]]
19
+ else
20
+ operations << [:add, [smanifest]]
21
+ end
22
+ end
23
+
24
+ # Only sync checksums for v1 manifests (v2 manifests provide integrity via hashes)
25
+ source_will_generate_v2 = !source.manifest.existing? # No existing manifest = will generate v2
26
+ dest_has_checksum = destination.manifest.existing_checksum
27
+ dest_has_v1_manifest = destination.manifest.existing? && destination.manifest.v1_format?
28
+
29
+ if source_will_generate_v2 && dest_has_checksum
30
+ # Transitioning to v2: remove old checksum
31
+ operations << [:remove, [dest_has_checksum]]
32
+ elsif !source_will_generate_v2 && (source.manifest.v1_format? || dest_has_v1_manifest)
33
+ # Both source and dest use v1 format: sync checksum
34
+ schecksum = source.manifest.generate_checksum
35
+ dchecksum = destination.manifest.existing_checksum
36
+ if schecksum != dchecksum
37
+ if dchecksum
38
+ operations << [:change, [dchecksum, schecksum]]
39
+ else
40
+ operations << [:add, [schecksum]]
41
+ end
42
+ end
43
+ end
44
+
45
+ operations.each do |method, records|
46
+ Logger.log(method, records, dry_run: dry_run)
47
+ destination.send(method, *records) unless dry_run
48
+ end
49
+ end
50
+ end
51
+ end
@@ -1,45 +1,103 @@
1
+ # typed: strict
2
+ require "sorbet-runtime"
3
+ require "zonesync/record_hash"
4
+
1
5
  module Zonesync
2
- class Validator < Struct.new(:operations, :destination)
3
- def self.call(...)
4
- new(...).call
6
+ Validator = Struct.new(:operations, :destination) do
7
+ extend T::Sig
8
+
9
+ sig { params(operations: T::Array[Operation], destination: Provider, force: T::Boolean).void }
10
+ def self.call(operations, destination, force: false)
11
+ new(operations, destination).call(force: force)
5
12
  end
6
13
 
7
- def call
8
- if operations.any? && !manifest.existing?
14
+ sig { params(force: T::Boolean).void }
15
+ def call(force: false)
16
+ if !force && operations.any? && !manifest.existing?
9
17
  raise MissingManifestError.new(manifest.generate)
10
18
  end
11
- if manifest.existing_checksum && manifest.existing_checksum != manifest.generate_checksum
19
+ # Only validate checksums for v1 manifests (v2 manifests provide integrity via hashes)
20
+ if !force && manifest.v1_format? && manifest.existing_checksum && manifest.existing_checksum != manifest.generate_checksum
12
21
  raise ChecksumMismatchError.new(manifest.existing_checksum, manifest.generate_checksum)
13
22
  end
14
23
  operations.each do |method, args|
15
- send(method, *args)
24
+ if method == :add
25
+ validate_addition args.first, force: force
26
+ end
16
27
  end
28
+ nil
17
29
  end
18
30
 
19
31
  private
20
32
 
33
+ sig { returns(Manifest) }
21
34
  def manifest
22
35
  destination.manifest
23
36
  end
24
37
 
25
- def add record
38
+ sig { params(record: Record, force: T::Boolean).void }
39
+ def validate_addition record, force: false
26
40
  return if manifest.matches?(record)
27
- shorthand = manifest.shorthand_for(record, with_type: true)
28
- conflicting_record = destination.records.find do |r|
29
- manifest.shorthand_for(r, with_type: true) == shorthand
41
+ return if force
42
+
43
+ # Use hash-based conflict detection when we have a v2 manifest
44
+ if manifest.existing? && !manifest.existing.rdata[1..-2].include?(";")
45
+ # V2 hash-based manifest: check for untracked records that conflict
46
+ record_hash = RecordHash.generate(record)
47
+ expected_hashes = manifest.existing.rdata[1..-2].split(",")
48
+
49
+ # Find conflicting records that would be overwritten by this addition
50
+ conflicting_record = destination.records.find do |r|
51
+ next if r.manifest? || r.checksum?
52
+
53
+ # Skip if this record is tracked in the manifest
54
+ r_hash = RecordHash.generate(r)
55
+ next if expected_hashes.include?(r_hash)
56
+
57
+ # Skip if it's exactly the same record (we're just starting to track it)
58
+ next if r.name == record.name && r.type == record.type && r.ttl == record.ttl && r.rdata == record.rdata
59
+
60
+ # Check for conflicts based on record type
61
+ case record.type
62
+ when "CNAME", "SOA"
63
+ # These types only allow one record per name
64
+ r.name == record.name && r.type == record.type
65
+ when "MX"
66
+ # MX records conflict if same name and same priority (first part of rdata)
67
+ if r.name == record.name && r.type == record.type
68
+ existing_priority = r.rdata.split(' ').first
69
+ new_priority = record.rdata.split(' ').first
70
+ existing_priority == new_priority
71
+ end
72
+ else
73
+ # For other types (A, AAAA, TXT, etc.), multiple records are allowed
74
+ # Only conflict if trying to add identical record (but we already checked that above)
75
+ false
76
+ end
77
+ end
78
+ else
79
+ # V1 name-based manifest or no manifest
80
+ if manifest.existing?
81
+ # V1 name-based manifest: use old shorthand logic
82
+ shorthand = manifest.shorthand_for(record, with_type: true)
83
+ conflicting_record = destination.records.find do |r|
84
+ manifest.shorthand_for(r, with_type: true) == shorthand
85
+ end
86
+ else
87
+ # No manifest: only conflict if exact same record exists
88
+ conflicting_record = destination.records.find do |r|
89
+ r.name == record.name &&
90
+ r.type == record.type &&
91
+ r.ttl == record.ttl &&
92
+ r.rdata == record.rdata
93
+ end
94
+ end
30
95
  end
96
+
31
97
  return if !conflicting_record
32
98
  return if conflicting_record == record
33
99
  raise Zonesync::ConflictError.new(conflicting_record, record)
34
100
  end
35
-
36
- def change *records
37
- # FIXME? is it possible to break something with a tracked changed record
38
- end
39
-
40
- def remove record
41
- # FIXME? is it possible to break something with a tracked removed record
42
- end
43
101
  end
44
102
  end
45
103
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zonesync
4
- VERSION = "0.9.0"
4
+ VERSION = "0.11.0"
5
5
  end