record_store 0.3.0 → 1.0.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/.gitignore +3 -9
- data/Gemfile +19 -2
- data/Gemfile.lock +178 -0
- data/{LICENSE.txt → LICENSE} +5 -5
- data/README.md +131 -44
- data/Rakefile +18 -1
- data/bin/record-store +7 -0
- data/bin/setup +8 -2
- data/bin/test +5 -0
- data/circle.yml +8 -0
- data/lib/record_store.rb +48 -104
- data/lib/record_store/changeset.rb +85 -0
- data/lib/record_store/cli.rb +255 -0
- data/lib/record_store/provider.rb +102 -0
- data/lib/record_store/provider/dnsimple.rb +158 -0
- data/lib/record_store/provider/dynect.rb +97 -0
- data/lib/record_store/record.rb +70 -0
- data/lib/record_store/record/a.rb +32 -0
- data/lib/record_store/record/aaaa.rb +32 -0
- data/lib/record_store/record/alias.rb +20 -0
- data/lib/record_store/record/cname.rb +20 -0
- data/lib/record_store/record/mx.rb +25 -0
- data/lib/record_store/record/ns.rb +20 -0
- data/lib/record_store/record/spf.rb +20 -0
- data/lib/record_store/record/srv.rb +27 -0
- data/lib/record_store/record/txt.rb +29 -0
- data/lib/record_store/version.rb +3 -0
- data/lib/record_store/zone.rb +193 -0
- data/lib/record_store/zone/config.rb +24 -0
- data/record_store.gemspec +30 -14
- data/template/Gemfile +3 -0
- data/template/bin/record-store +7 -0
- data/template/bin/setup +3 -0
- data/template/bin/test +5 -0
- data/template/config.yml +5 -0
- data/template/secrets.json +11 -0
- data/template/zones/dnsimple.example.com.yml +37 -0
- data/template/zones/dynect.example.com.yml +37 -0
- metadata +208 -22
- data/.travis.yml +0 -3
- data/bin/console +0 -14
@@ -0,0 +1,32 @@
|
|
1
|
+
module RecordStore
|
2
|
+
class Record::AAAA < Record
|
3
|
+
attr_accessor :address
|
4
|
+
|
5
|
+
validates_presence_of :address
|
6
|
+
validate :valid_address?
|
7
|
+
|
8
|
+
def initialize(record)
|
9
|
+
super
|
10
|
+
@address = record.fetch(:address)
|
11
|
+
end
|
12
|
+
|
13
|
+
def to_s
|
14
|
+
"[AAAARecord] #{fqdn} #{ttl} IN AAAA #{address}"
|
15
|
+
end
|
16
|
+
|
17
|
+
def rdata
|
18
|
+
{ address: address }
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def valid_address?
|
24
|
+
begin
|
25
|
+
ip = IPAddr.new(address)
|
26
|
+
errors.add(:address, 'is not an IPv6 address') unless ip.ipv6?
|
27
|
+
rescue IPAddr::InvalidAddressError => e
|
28
|
+
errors.add(:address, 'is invalid')
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module RecordStore
|
2
|
+
class Record::ALIAS < Record
|
3
|
+
attr_accessor :cname
|
4
|
+
|
5
|
+
validates :cname, presence: true, format: { with: Record::CNAME_REGEX, message: 'is not a fully qualified domain name' }
|
6
|
+
|
7
|
+
def initialize(record)
|
8
|
+
super
|
9
|
+
@cname = Record.ensure_ends_with_dot(record.fetch(:cname))
|
10
|
+
end
|
11
|
+
|
12
|
+
def rdata
|
13
|
+
{ cname: cname }
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_s
|
17
|
+
"[ALIASRecord] #{fqdn} #{ttl} IN ALIAS #{cname}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module RecordStore
|
2
|
+
class Record::CNAME < Record
|
3
|
+
attr_accessor :cname
|
4
|
+
|
5
|
+
validates :cname, presence: true, format: { with: Record::CNAME_REGEX, message: 'is not a fully qualified domain name' }
|
6
|
+
|
7
|
+
def initialize(record)
|
8
|
+
super
|
9
|
+
@cname = Record.ensure_ends_with_dot(record.fetch(:cname))
|
10
|
+
end
|
11
|
+
|
12
|
+
def rdata
|
13
|
+
{ cname: cname }
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_s
|
17
|
+
"[CNAMERecord] #{fqdn} #{ttl} IN CNAME #{cname}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module RecordStore
|
2
|
+
class Record::MX < Record
|
3
|
+
attr_accessor :exchange, :preference
|
4
|
+
|
5
|
+
validates :preference, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, presence: true
|
6
|
+
validates :exchange, presence: true, format: { with: Record::FQDN_REGEX, message: 'is not a fully qualified domain name' }
|
7
|
+
|
8
|
+
def initialize(record)
|
9
|
+
super
|
10
|
+
@exchange = Record.ensure_ends_with_dot(record.fetch(:exchange))
|
11
|
+
@preference = record.fetch(:preference)
|
12
|
+
end
|
13
|
+
|
14
|
+
def rdata
|
15
|
+
{
|
16
|
+
preference: preference,
|
17
|
+
exchange: exchange
|
18
|
+
}
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_s
|
22
|
+
"[MXRecord] #{fqdn} #{ttl} IN MX #{preference} #{exchange}"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module RecordStore
|
2
|
+
class Record::NS < Record
|
3
|
+
attr_accessor :nsdname
|
4
|
+
|
5
|
+
validates :nsdname, presence: true, format: { with: Record::FQDN_REGEX, message: 'is not a fully qualified domain name' }
|
6
|
+
|
7
|
+
def initialize(record)
|
8
|
+
super
|
9
|
+
@nsdname = Record.ensure_ends_with_dot(record.fetch(:nsdname))
|
10
|
+
end
|
11
|
+
|
12
|
+
def rdata
|
13
|
+
{ nsdname: nsdname }
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_s
|
17
|
+
"[NSRecord] #{fqdn} #{ttl} IN NS #{nsdname}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module RecordStore
|
2
|
+
class Record::SPF < Record
|
3
|
+
attr_accessor :txtdata
|
4
|
+
|
5
|
+
validates :txtdata, presence: true, length: { maximum: 255 }
|
6
|
+
|
7
|
+
def initialize(record)
|
8
|
+
super
|
9
|
+
@txtdata = record.fetch(:txtdata)
|
10
|
+
end
|
11
|
+
|
12
|
+
def to_s
|
13
|
+
"[SPFRecord] #{fqdn} #{ttl} IN SPF \"#{txtdata}\""
|
14
|
+
end
|
15
|
+
|
16
|
+
def rdata
|
17
|
+
{ txtdata: txtdata }
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module RecordStore
|
2
|
+
class Record::SRV < Record
|
3
|
+
attr_accessor :priority, :port, :weight, :target
|
4
|
+
|
5
|
+
validates :priority, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
6
|
+
validates :port, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
7
|
+
validates :weight, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
8
|
+
validates :target, presence: true, format: { with: Record::CNAME_REGEX, message: 'is not a fully qualified domain name' }
|
9
|
+
|
10
|
+
def initialize(record)
|
11
|
+
super
|
12
|
+
@priority = record.fetch(:priority)
|
13
|
+
@weight = record.fetch(:weight)
|
14
|
+
@port = record.fetch(:port)
|
15
|
+
@target = record.fetch(:target)
|
16
|
+
end
|
17
|
+
|
18
|
+
def to_s
|
19
|
+
"[SRVRecord] #{fqdn} #{ttl} IN SRV #{priority} #{weight} #{port} #{target}"
|
20
|
+
end
|
21
|
+
|
22
|
+
def rdata
|
23
|
+
{ priority: priority, port: port, weight: weight, target: target }
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module RecordStore
|
2
|
+
class Record::TXT < Record
|
3
|
+
attr_accessor :txtdata
|
4
|
+
|
5
|
+
validates :txtdata, presence: true, length: { maximum: 255 }
|
6
|
+
validate :escaped_semicolons
|
7
|
+
|
8
|
+
def initialize(record)
|
9
|
+
super
|
10
|
+
@txtdata = record.fetch(:txtdata)
|
11
|
+
end
|
12
|
+
|
13
|
+
def to_s
|
14
|
+
"[TXTRecord] #{fqdn} #{ttl} IN TXT \"#{txtdata}\""
|
15
|
+
end
|
16
|
+
|
17
|
+
def rdata
|
18
|
+
{ txtdata: txtdata }
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def escaped_semicolons
|
24
|
+
if txtdata =~ /([^\\]|\A);/
|
25
|
+
errors.add(:txtdata, 'has unescaped semicolons (See RFC 1035).')
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,193 @@
|
|
1
|
+
module RecordStore
|
2
|
+
class Zone
|
3
|
+
module YamlDefinitions
|
4
|
+
include Enumerable
|
5
|
+
|
6
|
+
def all
|
7
|
+
defined.values
|
8
|
+
end
|
9
|
+
|
10
|
+
def defined
|
11
|
+
@defined ||= yaml_files.inject({}) { |zones, file| zones.merge(load_yaml_file(file)) }
|
12
|
+
end
|
13
|
+
|
14
|
+
def [](name)
|
15
|
+
defined.fetch(name)
|
16
|
+
end
|
17
|
+
|
18
|
+
def each(&block)
|
19
|
+
defined.each(&block)
|
20
|
+
end
|
21
|
+
|
22
|
+
def find(name)
|
23
|
+
return unless File.exists?(zone_path = "#{RecordStore.zones_path}/#{name}.yml")
|
24
|
+
Zone.from_yaml_definition(*YAML.load_file(zone_path).first)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def load_yaml_file(filename)
|
30
|
+
result = {}
|
31
|
+
YAML.load_file(filename).each do |name, definition|
|
32
|
+
result[name] = Zone.from_yaml_definition(name, definition)
|
33
|
+
end
|
34
|
+
result
|
35
|
+
end
|
36
|
+
|
37
|
+
def yaml_files
|
38
|
+
Dir["#{RecordStore.zones_path}/*.yml"]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
extend YamlDefinitions
|
43
|
+
include ActiveModel::Validations
|
44
|
+
|
45
|
+
attr_accessor :name
|
46
|
+
attr_reader :config
|
47
|
+
attr_writer :records
|
48
|
+
|
49
|
+
validates :name, presence: true, format: { with: Record::FQDN_REGEX, message: 'is not a fully qualified domain name' }
|
50
|
+
validate :validate_records
|
51
|
+
validate :validate_config
|
52
|
+
validate :validate_all_records_are_unique
|
53
|
+
validate :validate_a_records_for_same_fqdn
|
54
|
+
validate :validate_cname_records_for_same_fqdn
|
55
|
+
validate :validate_cname_records_dont_point_to_root
|
56
|
+
validate :validate_provider_can_handle_zone_records
|
57
|
+
|
58
|
+
def self.from_yaml_definition(name, definition)
|
59
|
+
new(name, definition.deep_symbolize_keys)
|
60
|
+
end
|
61
|
+
|
62
|
+
def self.download(name, provider_name)
|
63
|
+
dns = new(name, config: {provider: provider_name}).provider
|
64
|
+
current_records = dns.retrieve_current_records
|
65
|
+
|
66
|
+
File.write("#{RecordStore.zones_path}/#{name}.yml", {
|
67
|
+
name => {
|
68
|
+
config: {
|
69
|
+
provider: provider_name,
|
70
|
+
ignore_patterns: [{type: "NS", fqdn: "#{name}."}],
|
71
|
+
},
|
72
|
+
records: current_records.map(&:to_hash).sort_by! {|r| [r.fetch(:fqdn), r.fetch(:type), r[:nsdname] || r[:address]]}
|
73
|
+
}
|
74
|
+
}.deep_stringify_keys.to_yaml.gsub("---\n", ''))
|
75
|
+
end
|
76
|
+
|
77
|
+
def initialize(name, records: [], config: {})
|
78
|
+
@name = Record.ensure_ends_with_dot(name)
|
79
|
+
@config = RecordStore::Zone::Config.new(config)
|
80
|
+
@records = build_records(records)
|
81
|
+
end
|
82
|
+
|
83
|
+
def changeset
|
84
|
+
current_records = Zone.filter_records(provider.retrieve_current_records, config.ignore_patterns)
|
85
|
+
|
86
|
+
Changeset.new(
|
87
|
+
current_records: current_records,
|
88
|
+
desired_records: records
|
89
|
+
)
|
90
|
+
end
|
91
|
+
|
92
|
+
def unchanged?
|
93
|
+
changeset.empty?
|
94
|
+
end
|
95
|
+
|
96
|
+
def records
|
97
|
+
@records_cache ||= Zone.filter_records(@records, config.ignore_patterns)
|
98
|
+
end
|
99
|
+
|
100
|
+
def config=(config)
|
101
|
+
@records_cache = nil
|
102
|
+
@config = config
|
103
|
+
end
|
104
|
+
|
105
|
+
def provider
|
106
|
+
Provider.const_get(config.provider).new(zone: name.gsub(/\.\z/, ''))
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
def build_records(records)
|
112
|
+
records.map { |record| Record.build_from_yaml_definition(record) }
|
113
|
+
end
|
114
|
+
|
115
|
+
def validate_records
|
116
|
+
records.each do |record|
|
117
|
+
unless record.fqdn.end_with?(name)
|
118
|
+
errors.add(:records, "record #{record} does not belong in zone #{name}")
|
119
|
+
end
|
120
|
+
|
121
|
+
unless record.valid?
|
122
|
+
errors.add(:records, "invalid record: #{record}")
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def validate_config
|
128
|
+
unless config.valid?
|
129
|
+
errors.add(:config, "invalid config: #{config}")
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def validate_all_records_are_unique
|
134
|
+
duplicates = records.select { |r| records.count(r) > 1 }
|
135
|
+
duplicates.uniq.each do |record|
|
136
|
+
errors.add(:records, "Duplicate record: #{record}")
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def validate_a_records_for_same_fqdn
|
141
|
+
a_records = records.select { |record| record.is_a?(Record::A) }.group_by(&:fqdn)
|
142
|
+
a_records.each do |fqdn, records|
|
143
|
+
if records.map(&:ttl).uniq.size > 1
|
144
|
+
errors.add(:records, "All A records for #{fqdn} should have the same TTL")
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def validate_cname_records_for_same_fqdn
|
150
|
+
cname_records = records.select { |record| record.is_a?(Record::CNAME) }
|
151
|
+
cname_records.each do |cname_record|
|
152
|
+
records.each do |record|
|
153
|
+
if record.fqdn == cname_record.fqdn && record != cname_record
|
154
|
+
case record.type
|
155
|
+
when 'SIG', 'NXT', 'KEY'
|
156
|
+
# this is fine
|
157
|
+
when 'CNAME'
|
158
|
+
errors.add(:records, "Multiple CNAME records are defined for #{record.fqdn}: #{record}")
|
159
|
+
else
|
160
|
+
errors.add(:records, "A CNAME record is defined for #{cname_record.fqdn}, so this record is not allowed: #{record}")
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def validate_cname_records_dont_point_to_root
|
168
|
+
cname_record = records.detect { |record| record.is_a?(Record::CNAME) && record.fqdn == @name }
|
169
|
+
|
170
|
+
unless cname_record.nil?
|
171
|
+
errors.add(:records, "A CNAME record cannot be defined on the root of the zone: #{cname_record}")
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
def self.filter_records(current_records, ignore_patterns)
|
176
|
+
ignore_patterns.inject(current_records) do |remaining_records, pattern|
|
177
|
+
remaining_records.reject do |record|
|
178
|
+
pattern.all? { |(key, value)| record.respond_to?(key) && value === record.send(key) }
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
def validate_provider_can_handle_zone_records
|
184
|
+
record_types = records.map(&:type).to_set
|
185
|
+
return unless config.valid?
|
186
|
+
provider_supported_record_types = Provider.const_get(config.provider).record_types
|
187
|
+
|
188
|
+
(record_types - provider_supported_record_types).each do |record_type|
|
189
|
+
errors.add(:records, "#{record_type} is a not a supported record type in #{config.provider}")
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module RecordStore
|
2
|
+
class Zone
|
3
|
+
class Config
|
4
|
+
include ActiveModel::Validations
|
5
|
+
|
6
|
+
attr_reader :ignore_patterns, :provider
|
7
|
+
|
8
|
+
validate :validate_zone_config
|
9
|
+
|
10
|
+
def initialize(ignore_patterns: [], provider: nil)
|
11
|
+
@ignore_patterns = ignore_patterns
|
12
|
+
@provider = provider
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def validate_zone_config
|
18
|
+
unless Provider.constants.include?(provider.to_s.to_sym)
|
19
|
+
errors.add(:provider, 'provider specified does not exist')
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
data/record_store.gemspec
CHANGED
@@ -1,20 +1,36 @@
|
|
1
|
-
|
1
|
+
lib = File.expand_path('../lib', __FILE__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
3
|
+
require 'record_store/version'
|
2
4
|
|
3
5
|
Gem::Specification.new do |spec|
|
4
|
-
spec.name
|
5
|
-
spec.version
|
6
|
-
spec.authors
|
7
|
-
spec.email
|
6
|
+
spec.name = 'record_store'
|
7
|
+
spec.version = RecordStore::VERSION
|
8
|
+
spec.authors = ['Willem van Bergen', 'Emil Stolarsky']
|
9
|
+
spec.email = ['willem@railsdoctors.com', 'emil@shopify.com']
|
8
10
|
|
9
|
-
spec.summary
|
10
|
-
spec.
|
11
|
-
spec.
|
11
|
+
spec.summary = 'Manage DNS using git'
|
12
|
+
spec.description = "Manage DNS through a git-based workflow. If you're looking for the original 'record_store', that has been renamed to 'sequel_record_store'."
|
13
|
+
spec.homepage = 'https://github.com/Shopify/record_store'
|
14
|
+
spec.license = 'MIT'
|
12
15
|
|
13
|
-
spec.files
|
14
|
-
spec.
|
15
|
-
spec.
|
16
|
-
spec.require_paths = ["lib"]
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|DESIGN)}) }
|
17
|
+
spec.executables = ['record-store']
|
18
|
+
spec.require_paths = ['lib']
|
17
19
|
|
18
|
-
spec.
|
19
|
-
|
20
|
+
spec.required_ruby_version = '>= 2.0'
|
21
|
+
|
22
|
+
spec.add_runtime_dependency 'thor'
|
23
|
+
spec.add_runtime_dependency 'activesupport', '~> 4.2'
|
24
|
+
spec.add_runtime_dependency 'activemodel', '~> 4.2'
|
25
|
+
spec.add_runtime_dependency 'fog'
|
26
|
+
spec.add_runtime_dependency 'fog-json'
|
27
|
+
spec.add_runtime_dependency 'fog-xml'
|
28
|
+
spec.add_runtime_dependency 'fog-dynect'
|
29
|
+
spec.add_runtime_dependency 'ejson'
|
30
|
+
|
31
|
+
spec.add_development_dependency 'rake'
|
32
|
+
spec.add_development_dependency 'bundler'
|
33
|
+
spec.add_development_dependency 'mocha'
|
34
|
+
spec.add_development_dependency 'vcr'
|
35
|
+
spec.add_development_dependency 'pry'
|
20
36
|
end
|