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.
@@ -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