record_store 0.3.0 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|