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.
@@ -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,3 @@
1
+ module RecordStore
2
+ VERSION = '1.0.0'
3
+ 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
- # coding: utf-8
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 = "record_store"
5
- spec.version = "0.3.0"
6
- spec.authors = ["Paul Barry"]
7
- spec.email = ["mail@paulbarry.com"]
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 = %q{Easily store records in your database with Sequel}
10
- spec.homepage = "http://github.com/pjb3/record_store"
11
- spec.license = "MIT"
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 = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
14
- spec.bindir = "exe"
15
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
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.add_development_dependency "bundler", "~> 1.9"
19
- spec.add_development_dependency "rake", "~> 10.0"
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