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 +4 -4
- data/CLAUDE.md +90 -0
- data/lib/zonesync/errors.rb +95 -10
- data/lib/zonesync/manifest.rb +48 -17
- data/lib/zonesync/provider.rb +26 -1
- data/lib/zonesync/record.rb +31 -0
- data/lib/zonesync/record_hash.rb +15 -0
- data/lib/zonesync/route53.rb +99 -2
- data/lib/zonesync/sync.rb +18 -7
- data/lib/zonesync/validator.rb +115 -19
- data/lib/zonesync/version.rb +1 -1
- data/lib/zonesync.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aaa6c630bddb382ab5b051983a3af48b5d59583a2e6befb51bf4098e3ccbdbb0
|
|
4
|
+
data.tar.gz: 7005f724efb994e55bc9d581780a8ba5711729ba208b9ef4c8fb77cc48d018bc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/zonesync/errors.rb
CHANGED
|
@@ -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(
|
|
9
|
-
def initialize
|
|
10
|
-
@
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 {
|
|
45
|
-
|
|
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
|
data/lib/zonesync/manifest.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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) }
|
data/lib/zonesync/provider.rb
CHANGED
|
@@ -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"
|
data/lib/zonesync/record.rb
CHANGED
|
@@ -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
|
data/lib/zonesync/route53.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
data/lib/zonesync/validator.rb
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 {
|
|
37
|
-
def
|
|
38
|
-
|
|
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
|
-
|
|
132
|
+
destination.records.find do |r|
|
|
41
133
|
manifest.shorthand_for(r, with_type: true) == shorthand
|
|
42
134
|
end
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
data/lib/zonesync/version.rb
CHANGED
data/lib/zonesync.rb
CHANGED
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.
|
|
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-
|
|
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
|