zonesync 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed23482c3eeed63566e03e79231562447d9f63c09a2d382151d6cd3b123b3502
4
- data.tar.gz: 45c154c9bd89d9f1ad97df54ec0c4d30ab49995716fbd3e519b4381f890c2259
3
+ metadata.gz: 3e2ac350c2fe58aaed3387c001755daba2e460747b09e12ca187e3643895cfec
4
+ data.tar.gz: b9c28c96bfcb6e4964060aa6fac6b05af5533d7903dd74b76850cc1d58904158
5
5
  SHA512:
6
- metadata.gz: 2870e1fd94f37248ddd609da2a90be47e9487f11e371043b48ed00465fbc727f69fd36614d83b8adf4ff7661bc90dfbbeb100c964c572a49b2ad97ab36b4ad97
7
- data.tar.gz: 3dcde654cdbdcaa806734b41c99286ba4724b812c58a4b22af7232bde1e5410892edda80b4475918b512738750a44a709c87c0af4b3483d6a9cc669bc12158b5
6
+ metadata.gz: 8861b5bbcb843c30e3017eb2989fc3391913452246d5a6d798b8ccac677430ae845f5189271a6a07d8f4b7f72f07db34153f480281a3e6d398e135c3b0b28cf1
7
+ data.tar.gz: 6a471b920e9cd971a023eca9992894c65f43ef9e6fffe9d873abb32706f265db98043fdeb7e749cd7a87feeda0faefac8bd4b1477f27059e28b8e793f3f091fe
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
@@ -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) }
@@ -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"
@@ -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,5 +1,6 @@
1
1
  # typed: strict
2
2
  require "sorbet-runtime"
3
+ require "zonesync/record_hash"
3
4
 
4
5
  module Zonesync
5
6
  Validator = Struct.new(:operations, :destination) do
@@ -12,15 +13,16 @@ module Zonesync
12
13
 
13
14
  sig { params(force: T::Boolean).void }
14
15
  def call(force: false)
15
- if operations.any? && !manifest.existing?
16
+ if !force && operations.any? && !manifest.existing?
16
17
  raise MissingManifestError.new(manifest.generate)
17
18
  end
18
- if !force && 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
19
21
  raise ChecksumMismatchError.new(manifest.existing_checksum, manifest.generate_checksum)
20
22
  end
21
23
  operations.each do |method, args|
22
24
  if method == :add
23
- validate_addition args.first
25
+ validate_addition args.first, force: force
24
26
  end
25
27
  end
26
28
  nil
@@ -33,13 +35,65 @@ module Zonesync
33
35
  destination.manifest
34
36
  end
35
37
 
36
- sig { params(record: Record).void }
37
- def validate_addition record
38
+ sig { params(record: Record, force: T::Boolean).void }
39
+ def validate_addition record, force: false
38
40
  return if manifest.matches?(record)
39
- shorthand = manifest.shorthand_for(record, with_type: true)
40
- conflicting_record = destination.records.find do |r|
41
- 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
42
95
  end
96
+
43
97
  return if !conflicting_record
44
98
  return if conflicting_record == record
45
99
  raise Zonesync::ConflictError.new(conflicting_record, record)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zonesync
4
- VERSION = "0.10.0"
4
+ VERSION = "0.11.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.11.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-09-18 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