zonesync 0.10.0 → 0.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed23482c3eeed63566e03e79231562447d9f63c09a2d382151d6cd3b123b3502
4
- data.tar.gz: 45c154c9bd89d9f1ad97df54ec0c4d30ab49995716fbd3e519b4381f890c2259
3
+ metadata.gz: aaa6c630bddb382ab5b051983a3af48b5d59583a2e6befb51bf4098e3ccbdbb0
4
+ data.tar.gz: 7005f724efb994e55bc9d581780a8ba5711729ba208b9ef4c8fb77cc48d018bc
5
5
  SHA512:
6
- metadata.gz: 2870e1fd94f37248ddd609da2a90be47e9487f11e371043b48ed00465fbc727f69fd36614d83b8adf4ff7661bc90dfbbeb100c964c572a49b2ad97ab36b4ad97
7
- data.tar.gz: 3dcde654cdbdcaa806734b41c99286ba4724b812c58a4b22af7232bde1e5410892edda80b4475918b512738750a44a709c87c0af4b3483d6a9cc669bc12158b5
6
+ metadata.gz: ca2cc5e0fefcbf14efe91cb80b84ef20b485715891d92b8f2cd1f58cb2b6193bf9a7a51e822fa046a516bb275a7c5a5fb98e19d2cb9ae08cee41208434986f9e
7
+ data.tar.gz: bb66c82041407b2e3d3a57e0c1c381dc9a7ac77fb1d09db52d61892fb76409541a3d5059f916205f00dbf304a71218a0df7f60c6fb995350a1928d8026e8b617
data/CLAUDE.md ADDED
@@ -0,0 +1,90 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ Zonesync is a Ruby gem that synchronizes DNS zone files with DNS providers (Cloudflare, Route53). It treats DNS configuration as code, enabling version control and CI/CD workflows for DNS records.
8
+
9
+ ## Commands
10
+
11
+ ### Development Commands
12
+ - `bundle install` - Install dependencies
13
+ - `bundle exec rake` - Run all tests (default task)
14
+ - `bundle exec rspec` - Run RSpec tests
15
+ - `bundle exec rspec spec/path/to/specific_spec.rb` - Run a single test file
16
+ - `bundle exec rspec spec/path/to/specific_spec.rb:123` - Run a specific test at line 123
17
+
18
+ ### Application Commands
19
+ - `bundle exec zonesync` - Sync Zonefile to configured DNS provider
20
+ - `bundle exec zonesync --dry-run` - Preview changes without applying them
21
+ - `bundle exec zonesync --force` - Force sync ignoring checksum mismatches
22
+ - `bundle exec zonesync generate` - Generate Zonefile from DNS provider
23
+
24
+ ### Type Checking (Sorbet)
25
+ - `bundle exec srb tc` - Run Sorbet type checker
26
+ - `bundle exec tapioca gem` - Generate RBI files for gems
27
+
28
+ ## Architecture
29
+
30
+ ### Core Components
31
+
32
+ **Provider Pattern**: Abstract base class `Provider` with concrete implementations:
33
+ - `Cloudflare` - Cloudflare DNS API integration
34
+ - `Route53` (AWS) - Route53 DNS API integration
35
+ - `Filesystem` - Local zone file operations
36
+ - `Memory` - In-memory provider for testing
37
+
38
+ **Key Classes**:
39
+ - `Sync` - Orchestrates synchronization between source and destination providers
40
+ - `Generate` - Generates zone files from DNS providers
41
+ - `Record` - Represents DNS records with type, name, content, TTL
42
+ - `Zonefile` - Parses and generates RFC-compliant zone files
43
+ - `Diff` - Calculates differences between record sets
44
+ - `Manifest` - Tracks which records are managed by zonesync
45
+ - `Validator` - Validates operations and handles conflicts
46
+
47
+ ### Data Flow
48
+
49
+ 1. **Sync Process**: `Zonefile` → `Provider.diff!()` → `operations[]` → `destination.apply()`
50
+ 2. **Generate Process**: DNS Provider → `Zonefile.generate()` → local file
51
+ 3. **Validation**: Checksum verification prevents conflicting external changes
52
+ 4. **Manifest**: TXT records track zonesync-managed records vs external ones
53
+
54
+ ### Safety Mechanisms
55
+
56
+ - **Checksum verification**: Detects external changes to managed records
57
+ - **Manifest tracking**: Distinguishes zonesync-managed vs external records
58
+ - **Force mode**: Bypass safety checks when needed
59
+ - **Dry-run mode**: Preview changes without applying them
60
+
61
+ ### Configuration
62
+
63
+ Credentials stored in Rails-style encrypted configuration:
64
+ - `config/credentials.yml.enc` - Encrypted credentials file
65
+ - `config/master.key` - Encryption key
66
+ - `RAILS_MASTER_KEY` env var - Alternative key source
67
+
68
+ ## Testing
69
+
70
+ - **RSpec** for testing framework
71
+ - **WebMock** for HTTP request stubbing
72
+ - **Feature specs** in `spec/features/` test end-to-end workflows
73
+ - **Unit specs** test individual classes and methods
74
+ - All tests should pass before committing changes
75
+
76
+ ## Type Safety
77
+
78
+ Uses **Sorbet** for gradual typing:
79
+ - All files have `# typed: strict` or similar headers
80
+ - Method signatures use `sig { ... }` blocks
81
+ - `extend T::Sig` enables signature checking
82
+ - RBI files in `sorbet/rbi/` define external gem types
83
+
84
+ ## Error Handling
85
+
86
+ Custom exceptions in `lib/zonesync/errors.rb`:
87
+ - `ConflictError` - Record conflicts
88
+ - `ChecksumMismatchError` - External changes detected
89
+ - `MissingManifestError` - Missing manifest record
90
+ - `DuplicateRecordError` - Duplicate record handling
@@ -1,22 +1,56 @@
1
1
  # typed: strict
2
2
  require "sorbet-runtime"
3
+ require "zonesync/record_hash"
3
4
 
4
5
  module Zonesync
6
+ class ValidationError < StandardError
7
+ extend T::Sig
8
+
9
+ sig { void }
10
+ def initialize
11
+ @errors = T.let([], T::Array[StandardError])
12
+ end
13
+
14
+ sig { params(error: StandardError).void }
15
+ def add(error)
16
+ @errors << error
17
+ end
18
+
19
+ sig { returns(T::Boolean) }
20
+ def any?
21
+ @errors.any?
22
+ end
23
+
24
+ sig { returns(String) }
25
+ def message
26
+ @errors.map(&:message).join("\n\n#{'-' * 60}\n\n")
27
+ end
28
+
29
+ sig { returns(T::Array[StandardError]) }
30
+ attr_reader :errors
31
+ end
32
+
5
33
  class ConflictError < StandardError
6
34
  extend T::Sig
7
35
 
8
- sig { params(existing: T.nilable(Record), new: Record).void }
9
- def initialize existing, new
10
- @existing = existing
11
- @new = new
36
+ sig { params(conflicts: T::Array[[T.nilable(Record), Record]]).void }
37
+ def initialize(conflicts)
38
+ @conflicts = conflicts
12
39
  end
13
40
 
14
41
  sig { returns(String) }
15
42
  def message
16
- <<~MSG
17
- The following untracked DNS record already exists and would be overwritten.
18
- existing: #{@existing}
19
- new: #{@new}
43
+ conflicts_text = @conflicts.sort_by { |_existing, new_rec| new_rec.name }.map do |existing_rec, new_rec|
44
+ " existing: #{existing_rec}\n new: #{new_rec}"
45
+ end.join("\n\n")
46
+
47
+ count = @conflicts.length
48
+ record_word = count == 1 ? "record" : "records"
49
+ exists_word = count == 1 ? "exists" : "exist"
50
+
51
+ <<~MSG.chomp
52
+ The following untracked DNS #{record_word} already #{exists_word} and would be overwritten:
53
+ #{conflicts_text}
20
54
  MSG
21
55
  end
22
56
  end
@@ -41,20 +75,71 @@ module Zonesync
41
75
  class ChecksumMismatchError < StandardError
42
76
  extend T::Sig
43
77
 
44
- sig { params(existing: T.nilable(Record), new: Record).void }
45
- def initialize existing, new
78
+ sig {
79
+ params(
80
+ existing: T.nilable(Record),
81
+ new: T.nilable(Record),
82
+ expected_record: T.nilable(Record),
83
+ actual_record: T.nilable(Record),
84
+ missing_hash: T.nilable(String)
85
+ ).void
86
+ }
87
+ def initialize(existing = nil, new = nil, expected_record: nil, actual_record: nil, missing_hash: nil)
46
88
  @existing = existing
47
89
  @new = new
90
+ @expected_record = expected_record
91
+ @actual_record = actual_record
92
+ @missing_hash = missing_hash
48
93
  end
49
94
 
50
95
  sig { returns(String) }
51
96
  def message
97
+ # V2 manifest integrity violation
98
+ if @missing_hash
99
+ return generate_v2_message
100
+ end
101
+
102
+ # V1 checksum mismatch
52
103
  <<~MSG
53
104
  The zonesync_checksum TXT record does not match the current state of the DNS records. This probably means that someone else has changed them.
54
105
  existing: #{@existing}
55
106
  new: #{@new}
56
107
  MSG
57
108
  end
109
+
110
+ private
111
+
112
+ sig { returns(String) }
113
+ def generate_v2_message
114
+ if @expected_record && @actual_record
115
+ # Record was modified
116
+ actual_hash = RecordHash.generate(@actual_record)
117
+ <<~MSG.chomp
118
+ The following tracked DNS record has been modified externally:
119
+ Expected: #{@expected_record.name} #{@expected_record.ttl} #{@expected_record.type} #{@expected_record.rdata} (hash: #{@missing_hash})
120
+ Actual: #{@actual_record.name} #{@actual_record.ttl} #{@actual_record.type} #{@actual_record.rdata} (hash: #{actual_hash})
121
+
122
+ This probably means someone else has changed it. Use --force to override.
123
+ MSG
124
+ elsif @expected_record
125
+ # Record was deleted
126
+ <<~MSG.chomp
127
+ The following tracked DNS record has been deleted externally:
128
+ Expected: #{@expected_record.name} #{@expected_record.ttl} #{@expected_record.type} #{@expected_record.rdata} (hash: #{@missing_hash})
129
+ Not found in current remote records.
130
+
131
+ This probably means someone else has deleted it. Use --force to override.
132
+ MSG
133
+ else
134
+ # Fallback: we don't have source records to look up details
135
+ <<~MSG.chomp
136
+ The following tracked DNS record has been modified or deleted externally:
137
+ Expected hash: #{@missing_hash} (not found in current records)
138
+
139
+ This probably means someone else has changed it. Use --force to override.
140
+ MSG
141
+ end
142
+ end
58
143
  end
59
144
 
60
145
  class DuplicateRecordError < StandardError
@@ -2,6 +2,7 @@
2
2
  require "sorbet-runtime"
3
3
 
4
4
  require "zonesync/record"
5
+ require "zonesync/record_hash"
5
6
  require "digest"
6
7
 
7
8
  module Zonesync
@@ -22,13 +23,7 @@ module Zonesync
22
23
 
23
24
  sig { returns(Zonesync::Record) }
24
25
  def generate
25
- Record.new(
26
- name: "zonesync_manifest.#{zone.origin}",
27
- type: "TXT",
28
- ttl: 3600,
29
- rdata: generate_rdata,
30
- comment: nil,
31
- )
26
+ generate_v2
32
27
  end
33
28
 
34
29
  sig { returns(T.nilable(Zonesync::Record)) }
@@ -49,6 +44,18 @@ module Zonesync
49
44
  )
50
45
  end
51
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
+
52
59
  sig { params(record: Zonesync::Record).returns(T::Boolean) }
53
60
  def diffable? record
54
61
  if existing?
@@ -58,19 +65,43 @@ module Zonesync
58
65
  end
59
66
  end
60
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
+
61
82
  sig { params(record: Zonesync::Record).returns(T::Boolean) }
62
83
  def matches? record
63
84
  return false unless existing?
64
- hash = T.must(existing)
65
- .rdata[1..-2] # remove quotes
66
- .split(";")
67
- .reduce({}) do |hash, pair|
68
- type, short_names = pair.split(":")
69
- hash[type] = short_names.split(",")
70
- hash
71
- end
72
- shorthands = hash.fetch(record.type, [])
73
- 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
74
105
  end
75
106
 
76
107
  sig { params(record: Zonesync::Record, with_type: T::Boolean).returns(String) }
@@ -26,7 +26,7 @@ module Zonesync
26
26
  sig { params(other: Provider, force: T::Boolean).returns(T::Array[Operation]) }
27
27
  def diff! other, force: false
28
28
  operations = diff(other).call
29
- Validator.call(operations, self, force: force)
29
+ Validator.call(operations, self, other, force: force)
30
30
  operations
31
31
  end
32
32
 
@@ -98,6 +98,31 @@ module Zonesync
98
98
  return
99
99
  end
100
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
125
+ end
101
126
  end
102
127
 
103
128
  require "zonesync/cloudflare"
@@ -53,6 +53,37 @@ module Zonesync
53
53
  string << " ; #{comment}" if comment
54
54
  string
55
55
  end
56
+
57
+ sig { params(other: Record).returns(T::Boolean) }
58
+ def identical_to?(other)
59
+ name == other.name && type == other.type && ttl == other.ttl && rdata == other.rdata
60
+ end
61
+
62
+ sig { params(other: Record).returns(T::Boolean) }
63
+ def conflicts_with?(other)
64
+ return false unless name == other.name && type == other.type
65
+
66
+ case type
67
+ when "CNAME", "SOA"
68
+ true
69
+ when "MX"
70
+ existing_priority = rdata.split(' ').first
71
+ new_priority = other.rdata.split(' ').first
72
+ existing_priority == new_priority
73
+ else
74
+ false
75
+ end
76
+ end
77
+
78
+ sig { params(type: String).returns(T::Boolean) }
79
+ def self.single_record_per_name?(type)
80
+ type == "CNAME" || type == "SOA"
81
+ end
82
+
83
+ sig { params(records: T::Array[Record]).returns(T::Array[Record]) }
84
+ def self.non_meta(records)
85
+ records.reject { |r| r.manifest? || r.checksum? }
86
+ end
56
87
  end
57
88
  end
58
89
 
@@ -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
@@ -4,6 +4,7 @@ require "sorbet-runtime"
4
4
  require "zonesync/record"
5
5
  require "zonesync/http"
6
6
  require "rexml/document"
7
+ require "erb"
7
8
 
8
9
  module Zonesync
9
10
  class Route53 < Provider
@@ -21,7 +22,58 @@ module Zonesync
21
22
 
22
23
  sig { params(record: Record).void }
23
24
  def remove(record)
24
- 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
25
77
  end
26
78
 
27
79
  sig { params(old_record: Record, new_record: Record).void }
@@ -34,7 +86,19 @@ module Zonesync
34
86
  def add(record)
35
87
  add_with_duplicate_handling(record) do
36
88
  begin
37
- change_record("CREATE", record)
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
38
102
  rescue RuntimeError => e
39
103
  # Convert Route53-specific duplicate error to standard exception
40
104
  if e.message.include?("RRSet already exists")
@@ -75,6 +139,39 @@ module Zonesync
75
139
  XML
76
140
  end
77
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
+
78
175
  sig { params(el: REXML::Element).returns(T::Array[Record]) }
79
176
  def to_records(el)
80
177
  el.elements.collect("ResourceRecords/ResourceRecord") do |rr|
data/lib/zonesync/sync.rb CHANGED
@@ -21,13 +21,24 @@ module Zonesync
21
21
  end
22
22
  end
23
23
 
24
- schecksum = source.manifest.generate_checksum
25
- dchecksum = destination.manifest.existing_checksum
26
- if schecksum != dchecksum
27
- if dchecksum
28
- operations << [:change, [dchecksum, schecksum]]
29
- else
30
- operations << [:add, [schecksum]]
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
31
42
  end
32
43
  end
33
44
 
@@ -1,28 +1,44 @@
1
1
  # typed: strict
2
2
  require "sorbet-runtime"
3
+ require "zonesync/record_hash"
3
4
 
4
5
  module Zonesync
5
- Validator = Struct.new(:operations, :destination) do
6
+ Validator = Struct.new(:operations, :destination, :source) do
6
7
  extend T::Sig
7
8
 
8
- sig { params(operations: T::Array[Operation], destination: Provider, force: T::Boolean).void }
9
- def self.call(operations, destination, force: false)
10
- new(operations, destination).call(force: force)
9
+ sig { params(operations: T::Array[Operation], destination: Provider, source: T.nilable(Provider), force: T::Boolean).void }
10
+ def self.call(operations, destination, source = nil, force: false)
11
+ new(operations, destination, source).call(force: force)
11
12
  end
12
13
 
13
14
  sig { params(force: T::Boolean).void }
14
15
  def call(force: false)
15
- if operations.any? && !manifest.existing?
16
- raise MissingManifestError.new(manifest.generate)
16
+ validation_error = ValidationError.new
17
+
18
+ if !force && operations.any? && !manifest.existing?
19
+ validation_error.add(MissingManifestError.new(manifest.generate))
17
20
  end
18
- if !force && manifest.existing_checksum && manifest.existing_checksum != manifest.generate_checksum
19
- raise ChecksumMismatchError.new(manifest.existing_checksum, manifest.generate_checksum)
21
+
22
+ if !force && manifest.v1_format? && manifest.existing_checksum && manifest.existing_checksum != manifest.generate_checksum
23
+ validation_error.add(ChecksumMismatchError.new(manifest.existing_checksum, manifest.generate_checksum))
20
24
  end
21
- operations.each do |method, args|
22
- if method == :add
23
- validate_addition args.first
24
- end
25
+
26
+ if !force && manifest.v2_format?
27
+ integrity_error = validate_v2_manifest_integrity
28
+ validation_error.add(integrity_error) if integrity_error
25
29
  end
30
+
31
+ conflicts = operations.map do |method, args|
32
+ method == :add ? validate_addition(args.first, force: force) : nil
33
+ end.compact
34
+ validation_error.add(ConflictError.new(conflicts)) if conflicts.any?
35
+
36
+ if validation_error.errors.length == 1
37
+ raise validation_error.errors.first
38
+ elsif validation_error.errors.length > 1
39
+ raise validation_error
40
+ end
41
+
26
42
  nil
27
43
  end
28
44
 
@@ -33,16 +49,96 @@ module Zonesync
33
49
  destination.manifest
34
50
  end
35
51
 
36
- sig { params(record: Record).void }
37
- def validate_addition record
38
- return if manifest.matches?(record)
52
+ sig { returns(T.nilable(ChecksumMismatchError)) }
53
+ def validate_v2_manifest_integrity
54
+ manifest_data = T.must(manifest.existing).rdata[1..-2]
55
+ expected_hashes = manifest_data.split(",")
56
+ actual_records = Record.non_meta(destination.records)
57
+ actual_hash_to_record = actual_records.map { |r| [RecordHash.generate(r), r] }.to_h
58
+
59
+ missing_hash = expected_hashes.find { |hash| !actual_hash_to_record.key?(hash) }
60
+ return nil unless missing_hash
61
+
62
+ expected_record = find_expected_record(missing_hash)
63
+ actual_record = find_modified_record(expected_record, actual_records)
64
+
65
+ ChecksumMismatchError.new(
66
+ expected_record: expected_record,
67
+ actual_record: actual_record,
68
+ missing_hash: missing_hash
69
+ )
70
+ end
71
+
72
+ sig { params(missing_hash: String).returns(T.nilable(Record)) }
73
+ def find_expected_record(missing_hash)
74
+ return nil unless source
75
+
76
+ source_records = Record.non_meta(source.records)
77
+ source_records.find { |r| RecordHash.generate(r) == missing_hash }
78
+ end
79
+
80
+ sig { params(expected_record: T.nilable(Record), actual_records: T::Array[Record]).returns(T.nilable(Record)) }
81
+ def find_modified_record(expected_record, actual_records)
82
+ return nil unless expected_record
83
+
84
+ # For CNAME and SOA, only one record per name is allowed, so check for modification
85
+ # For other types (A, AAAA, TXT, MX), only check if there's exactly one record
86
+ # with that name/type - if there are multiples, we can't determine which one it "became"
87
+ if Record.single_record_per_name?(expected_record.type)
88
+ actual_records.find do |r|
89
+ r.name == expected_record.name && r.type == expected_record.type
90
+ end
91
+ else
92
+ matching_records = actual_records.select do |r|
93
+ r.name == expected_record.name && r.type == expected_record.type
94
+ end
95
+ matching_records.first if matching_records.count == 1
96
+ end
97
+ end
98
+
99
+ sig { params(record: Record, force: T::Boolean).returns(T.nilable([T.nilable(Record), Record])) }
100
+ def validate_addition record, force: false
101
+ return nil if manifest.matches?(record)
102
+ return nil if force
103
+
104
+ conflicting_record = if manifest.v2_format?
105
+ expected_hashes = manifest.existing.rdata[1..-2].split(",")
106
+ find_v2_conflict(record, expected_hashes)
107
+ elsif manifest.existing?
108
+ find_v1_conflict(record)
109
+ else
110
+ find_unmanaged_conflict(record)
111
+ end
112
+
113
+ return nil if !conflicting_record
114
+ return nil if conflicting_record == record
115
+ [conflicting_record, record]
116
+ end
117
+
118
+ sig { params(record: Record, expected_hashes: T::Array[String]).returns(T.nilable(Record)) }
119
+ def find_v2_conflict(record, expected_hashes)
120
+ destination.records.find do |r|
121
+ next if r.manifest? || r.checksum?
122
+ next if expected_hashes.include?(RecordHash.generate(r))
123
+ next if r.identical_to?(record)
124
+
125
+ r.conflicts_with?(record)
126
+ end
127
+ end
128
+
129
+ sig { params(record: Record).returns(T.nilable(Record)) }
130
+ def find_v1_conflict(record)
39
131
  shorthand = manifest.shorthand_for(record, with_type: true)
40
- conflicting_record = destination.records.find do |r|
132
+ destination.records.find do |r|
41
133
  manifest.shorthand_for(r, with_type: true) == shorthand
42
134
  end
43
- return if !conflicting_record
44
- return if conflicting_record == record
45
- raise Zonesync::ConflictError.new(conflicting_record, record)
135
+ end
136
+
137
+ sig { params(record: Record).returns(T.nilable(Record)) }
138
+ def find_unmanaged_conflict(record)
139
+ destination.records.find do |r|
140
+ r.identical_to?(record)
141
+ end
46
142
  end
47
143
  end
48
144
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zonesync
4
- VERSION = "0.10.0"
4
+ VERSION = "0.12.0"
5
5
  end
data/lib/zonesync.rb CHANGED
@@ -7,6 +7,7 @@ require "zonesync/provider"
7
7
  require "zonesync/cli"
8
8
  require "zonesync/rake"
9
9
  require "zonesync/errors"
10
+ require "zonesync/record_hash"
10
11
 
11
12
  begin # optional active_support dependency
12
13
  require "active_support"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zonesync
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Micah Geisel
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2025-07-19 00:00:00.000000000 Z
12
+ date: 2025-10-19 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: diff-lcs
@@ -148,6 +148,7 @@ extra_rdoc_files: []
148
148
  files:
149
149
  - ".envrc"
150
150
  - ".rspec"
151
+ - CLAUDE.md
151
152
  - Gemfile
152
153
  - LICENSE.txt
153
154
  - README.md
@@ -166,6 +167,7 @@ files:
166
167
  - lib/zonesync/provider.rb
167
168
  - lib/zonesync/rake.rb
168
169
  - lib/zonesync/record.rb
170
+ - lib/zonesync/record_hash.rb
169
171
  - lib/zonesync/route53.rb
170
172
  - lib/zonesync/sync.rb
171
173
  - lib/zonesync/validator.rb