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.
- checksums.yaml +4 -4
- data/CLAUDE.md +90 -0
- data/Gemfile +1 -0
- data/lib/zonesync/cli.rb +9 -0
- data/lib/zonesync/cloudflare.rb +64 -39
- data/lib/zonesync/diff.rb +10 -1
- data/lib/zonesync/errors.rb +35 -0
- data/lib/zonesync/generate.rb +14 -0
- data/lib/zonesync/http.rb +24 -8
- data/lib/zonesync/logger.rb +20 -15
- data/lib/zonesync/manifest.rb +68 -21
- data/lib/zonesync/parser.rb +337 -0
- data/lib/zonesync/provider.rb +105 -26
- data/lib/zonesync/record.rb +18 -23
- data/lib/zonesync/record_hash.rb +15 -0
- data/lib/zonesync/route53.rb +146 -28
- data/lib/zonesync/sync.rb +51 -0
- data/lib/zonesync/validator.rb +77 -19
- data/lib/zonesync/version.rb +1 -1
- data/lib/zonesync/zonefile.rb +22 -311
- data/lib/zonesync.rb +28 -60
- data/sorbet/config +4 -0
- data/sorbet/rbi/annotations/.gitattributes +1 -0
- data/sorbet/rbi/annotations/activesupport.rbi +457 -0
- data/sorbet/rbi/annotations/minitest.rbi +119 -0
- data/sorbet/rbi/annotations/webmock.rbi +9 -0
- data/sorbet/rbi/gems/.gitattributes +1 -0
- data/sorbet/rbi/gems/activesupport@8.0.1.rbi +18474 -0
- data/sorbet/rbi/gems/addressable@2.8.7.rbi +1994 -0
- data/sorbet/rbi/gems/base64@0.2.0.rbi +507 -0
- data/sorbet/rbi/gems/benchmark@0.4.0.rbi +618 -0
- data/sorbet/rbi/gems/bigdecimal@3.1.9.rbi +9 -0
- data/sorbet/rbi/gems/concurrent-ruby@1.3.4.rbi +11645 -0
- data/sorbet/rbi/gems/connection_pool@2.4.1.rbi +9 -0
- data/sorbet/rbi/gems/crack@1.0.0.rbi +145 -0
- data/sorbet/rbi/gems/date@3.4.1.rbi +75 -0
- data/sorbet/rbi/gems/diff-lcs@1.5.1.rbi +1131 -0
- data/sorbet/rbi/gems/drb@2.2.1.rbi +1347 -0
- data/sorbet/rbi/gems/erubi@1.13.1.rbi +155 -0
- data/sorbet/rbi/gems/hashdiff@1.1.2.rbi +353 -0
- data/sorbet/rbi/gems/i18n@1.14.6.rbi +2275 -0
- data/sorbet/rbi/gems/io-console@0.8.0.rbi +9 -0
- data/sorbet/rbi/gems/logger@1.6.4.rbi +940 -0
- data/sorbet/rbi/gems/minitest@5.25.4.rbi +1547 -0
- data/sorbet/rbi/gems/netrc@0.11.0.rbi +159 -0
- data/sorbet/rbi/gems/parallel@1.26.3.rbi +291 -0
- data/sorbet/rbi/gems/polyglot@0.3.5.rbi +42 -0
- data/sorbet/rbi/gems/prism@1.3.0.rbi +40040 -0
- data/sorbet/rbi/gems/psych@5.2.2.rbi +1785 -0
- data/sorbet/rbi/gems/public_suffix@6.0.1.rbi +936 -0
- data/sorbet/rbi/gems/rake@13.2.1.rbi +3028 -0
- data/sorbet/rbi/gems/rbi@0.2.2.rbi +4527 -0
- data/sorbet/rbi/gems/rdoc@6.10.0.rbi +12766 -0
- data/sorbet/rbi/gems/reline@0.6.0.rbi +9 -0
- data/sorbet/rbi/gems/rexml@3.4.0.rbi +4974 -0
- data/sorbet/rbi/gems/rspec-core@3.13.2.rbi +10896 -0
- data/sorbet/rbi/gems/rspec-expectations@3.13.3.rbi +8183 -0
- data/sorbet/rbi/gems/rspec-mocks@3.13.2.rbi +5341 -0
- data/sorbet/rbi/gems/rspec-support@3.13.2.rbi +1630 -0
- data/sorbet/rbi/gems/rspec@3.13.0.rbi +83 -0
- data/sorbet/rbi/gems/securerandom@0.4.1.rbi +75 -0
- data/sorbet/rbi/gems/spoom@1.5.0.rbi +4932 -0
- data/sorbet/rbi/gems/stringio@3.1.2.rbi +9 -0
- data/sorbet/rbi/gems/tapioca@0.16.6.rbi +3611 -0
- data/sorbet/rbi/gems/thor@1.3.2.rbi +4378 -0
- data/sorbet/rbi/gems/treetop@1.6.12.rbi +1895 -0
- data/sorbet/rbi/gems/tzinfo@2.0.6.rbi +5918 -0
- data/sorbet/rbi/gems/uri@1.0.2.rbi +2340 -0
- data/sorbet/rbi/gems/webmock@3.24.0.rbi +1780 -0
- data/sorbet/rbi/gems/yard-sorbet@0.9.0.rbi +435 -0
- data/sorbet/rbi/gems/yard@0.9.37.rbi +18379 -0
- data/sorbet/rbi/todo.rbi +7 -0
- data/sorbet/tapioca/config.yml +13 -0
- data/sorbet/tapioca/require.rb +4 -0
- data/zonesync.gemspec +3 -0
- metadata +102 -2
data/lib/zonesync/record.rb
CHANGED
@@ -1,32 +1,22 @@
|
|
1
|
+
# typed: strict
|
2
|
+
require "sorbet-runtime"
|
3
|
+
|
1
4
|
module Zonesync
|
2
|
-
|
3
|
-
|
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
|
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
|
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
|
data/lib/zonesync/route53.rb
CHANGED
@@ -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
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
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
|
-
|
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
|
-
|
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(
|
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
|
40
|
-
<Type>#{record
|
41
|
-
<TTL>#{record
|
126
|
+
<Name>#{record.name}</Name>
|
127
|
+
<Type>#{record.type}</Type>
|
128
|
+
<TTL>#{record.ttl}</TTL>
|
42
129
|
<ResourceRecords>
|
43
130
|
<ResourceRecord>
|
44
|
-
<Value>#{record
|
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
|
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
|
-
|
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/#{
|
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}/#{
|
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(
|
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=#{
|
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
|
data/lib/zonesync/validator.rb
CHANGED
@@ -1,45 +1,103 @@
|
|
1
|
+
# typed: strict
|
2
|
+
require "sorbet-runtime"
|
3
|
+
require "zonesync/record_hash"
|
4
|
+
|
1
5
|
module Zonesync
|
2
|
-
|
3
|
-
|
4
|
-
|
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
|
-
|
8
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
38
|
+
sig { params(record: Record, force: T::Boolean).void }
|
39
|
+
def validate_addition record, force: false
|
26
40
|
return if manifest.matches?(record)
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
|
data/lib/zonesync/version.rb
CHANGED