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.
data/Rakefile CHANGED
@@ -1 +1,18 @@
1
- require "bundler/gem_tasks"
1
+ require 'rake/testtask'
2
+ require 'bundler/gem_tasks'
3
+
4
+ $LOAD_PATH.unshift(__dir__ + "/lib")
5
+ require 'record_store'
6
+
7
+ Rake::TestTask.new do |t|
8
+ t.libs << "test"
9
+ t.test_files = FileList['test/**/*_test.rb']
10
+ end
11
+
12
+ task :validate do
13
+ record_store = RecordStore::CLI.new
14
+ record_store.validate_records
15
+ record_store.validate_all_present
16
+ end
17
+
18
+ task default: [:test]
data/bin/record-store ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $:.unshift File.expand_path('../../lib', __FILE__)
4
+ require 'bundler/setup'
5
+ require 'record_store'
6
+
7
+ RecordStore::CLI.start
data/bin/setup CHANGED
@@ -2,6 +2,12 @@
2
2
  set -euo pipefail
3
3
  IFS=$'\n\t'
4
4
 
5
- bundle install
5
+ cd "$(dirname "$0")/.."
6
+ ROOT_DIR=$(pwd)
6
7
 
7
- # Do any other automated setup that you need to do here
8
+ bundle check || bundle install
9
+
10
+ if [ ! -d $ROOT_DIR/dev ]; then
11
+ echo 'Created development directory dev/'
12
+ cp -R template dev
13
+ fi
data/bin/test ADDED
@@ -0,0 +1,5 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle exec rake test
data/circle.yml ADDED
@@ -0,0 +1,8 @@
1
+ machine:
2
+ ruby:
3
+ version: 2.1.1
4
+
5
+ test:
6
+ override:
7
+ - bin/setup
8
+ - bundle exec rake test
data/lib/record_store.rb CHANGED
@@ -1,122 +1,66 @@
1
- class RecordStore
2
- VERSION = "0.3.0"
1
+ require 'json'
2
+ require 'yaml'
3
+ require 'active_support'
4
+ require 'active_support/core_ext/hash/keys'
5
+ require 'active_support/core_ext/string'
6
+ require 'active_model'
7
+ require 'active_model/validations'
8
+ require 'ipaddr'
9
+ require 'thor'
10
+ require 'pathname'
11
+
12
+ require 'record_store/record'
13
+ require 'record_store/record/a'
14
+ require 'record_store/record/aaaa'
15
+ require 'record_store/record/alias'
16
+ require 'record_store/record/cname'
17
+ require 'record_store/record/mx'
18
+ require 'record_store/record/ns'
19
+ require 'record_store/record/txt'
20
+ require 'record_store/record/spf'
21
+ require 'record_store/record/srv'
22
+ require 'record_store/zone'
23
+ require 'record_store/zone/config'
24
+ require 'record_store/changeset'
25
+ require 'record_store/provider'
26
+ require 'record_store/provider/dynect'
27
+ require 'record_store/provider/dnsimple'
28
+ require 'record_store/cli'
29
+
30
+ module RecordStore
31
+ MAXIMUM_REMOVALS = 20
3
32
 
4
33
  class << self
5
- def database
6
- raise "You must setup your database"
7
- end
8
-
9
- def dataset_name
10
- @dataset_name ||= name.sub(/Store$/,'').tableize.to_sym
11
- end
12
-
13
- def dataset
14
- database[dataset_name]
15
- end
34
+ attr_writer :secrets_path
35
+ attr_writer :zones_path
16
36
 
17
- def get(id)
18
- dataset.where(id: id).first
37
+ def secrets_path
38
+ @secrets_path ||= File.expand_path(config.fetch('secrets_path'), File.dirname(config_path))
19
39
  end
20
- alias_method :[], :get
21
40
 
22
- def put(record)
23
- new(record).put
41
+ def zones_path
42
+ @zones_path ||= Pathname.new(File.expand_path(config.fetch('zones_path'), File.dirname(config_path))).realpath.to_s
24
43
  end
25
- alias_method :<<, :put
26
44
 
27
- def type_column_map(database)
28
- @type_column_map ||= database.schema(dataset_name).each_with_object({}) do |(column,metadata),map|
29
- map[metadata[:db_type]] ||= []
30
- map[metadata[:db_type]] << column
31
- end
45
+ def config_path
46
+ @config_path ||= File.expand_path('config.yml', Dir.pwd)
32
47
  end
33
48
 
34
- def validations
35
- @validations ||= []
49
+ def config_path=(config_path)
50
+ @config = @zones_path = @secrets_path = nil
51
+ @config_path = config_path
36
52
  end
37
53
 
38
- def required_attributes(*record)
39
- record.each do |attribute|
40
- validations << proc do
41
- errors << "#{attribute.to_s.titleize} is required" unless @record[attribute].present?
42
- end
43
- end
54
+ def config
55
+ @config ||= YAML.load_file(config_path)
44
56
  end
45
57
 
46
- def required_foreign_key(attribute, table)
47
- validations << proc do
48
- if @record[attribute].blank?
49
- errors << "#{attribute} is required"
50
- elsif database[table].where(id: @record[attribute]).get(:id).blank?
51
- errors << "no #{table.to_s.singularize} with id=#{@record[attribute]}"
52
- end
53
- end
58
+ def defined_zones
59
+ @defined_zones ||= Zone.all.map(&:name)
54
60
  end
55
- end
56
-
57
- def initialize(record)
58
- @record = record
59
- end
60
61
 
61
- def put
62
- typecast
63
- transform
64
- return { errors: 'no record provided' } if @record.blank?
65
- validate
66
- return { errors: errors } if errors.present?
67
-
68
- save
69
- end
70
-
71
- def save
72
- if exists?
73
- update
74
- else
75
- insert
62
+ def expected_zones
63
+ config.fetch('zones')
76
64
  end
77
- @record
78
- end
79
-
80
- def database
81
- self.class.database
82
- end
83
-
84
- def dataset
85
- self.class.dataset
86
- end
87
-
88
- def errors
89
- @errors ||= []
90
- end
91
-
92
- def typecast
93
- if database.database_type == :postgres
94
- Array(self.class.type_column_map(database)['json']).each do |json_column|
95
- if @record[json_column]
96
- @record[json_column] = Sequel.pg_json(@record[json_column])
97
- end
98
- end
99
- end
100
- end
101
-
102
- def transform
103
- end
104
-
105
- def validate
106
- self.class.validations.each do |validation|
107
- instance_eval(&validation)
108
- end
109
- end
110
-
111
- def exists?
112
- !dataset.select(1).where(id: @record[:id]).empty?
113
- end
114
-
115
- def insert
116
- @record[:id] = dataset.insert(@record.except(:errors))
117
- end
118
-
119
- def update
120
- dataset.where(id: @record[:id]).update(@record.except(:id, :errors))
121
65
  end
122
66
  end
@@ -0,0 +1,85 @@
1
+ module RecordStore
2
+ class Changeset
3
+ class Change
4
+ attr_accessor :type, :record, :id
5
+
6
+ def initialize(type: nil, record: nil, id: nil)
7
+ @type, @record, @id = type, record, id
8
+ end
9
+
10
+ def self.addition(record)
11
+ new(type: :addition, record: record)
12
+ end
13
+
14
+ def self.removal(record)
15
+ new(type: :removal, record: record)
16
+ end
17
+
18
+ def self.update(id, record)
19
+ raise ArgumentError.new('id cannot be nil') if id.nil?
20
+ new(type: :update, record: record, id: id)
21
+ end
22
+
23
+ def removal?
24
+ type == :removal
25
+ end
26
+
27
+ def addition?
28
+ type == :addition
29
+ end
30
+
31
+ def update?
32
+ type == :update
33
+ end
34
+ end
35
+
36
+ attr_reader :current_records, :desired_records, :removals, :additions, :updates
37
+
38
+ def initialize(current_records: [], desired_records: [])
39
+ @current_records, @desired_records = Set.new(current_records), Set.new(desired_records)
40
+ @additions, @removals, @updates = [], [], []
41
+
42
+ build_changeset
43
+ end
44
+
45
+ def unchanged
46
+ current_records & desired_records
47
+ end
48
+
49
+ def changes
50
+ updates.to_a + removals.to_a + additions.to_a
51
+ end
52
+
53
+ def empty?
54
+ changes.empty?
55
+ end
56
+
57
+ private
58
+
59
+ def build_changeset
60
+ current_records_set = (current_records - unchanged).group_by(&:key)
61
+ desired_records_set = (desired_records - unchanged).group_by(&:key)
62
+ current_records_set.default_proc = desired_records_set.default_proc = Proc.new{Array.new}
63
+
64
+ record_keys = current_records_set.keys | desired_records_set.keys
65
+
66
+ diff = record_keys.flat_map do |key|
67
+ # the array being zipped over must be bigger then or equal to the other array for zip to work
68
+ buffer = [0, desired_records_set[key].size - current_records_set[key].size].max
69
+ temp_diff = current_records_set[key] + [nil] * buffer
70
+
71
+ temp_diff.zip(desired_records_set[key])
72
+ end
73
+
74
+ diff.each do |before_rr, after_rr|
75
+ if before_rr.nil?
76
+ @additions << Change.addition(after_rr)
77
+ elsif after_rr.nil?
78
+ @removals << Change.removal(before_rr)
79
+ else
80
+ @updates << Change.update(before_rr.id, after_rr)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,255 @@
1
+ module RecordStore
2
+ class CLI < Thor
3
+ class_option :config, desc: 'Path to config.yml', aliases: '-c'
4
+
5
+ def initialize(*args)
6
+ super
7
+ RecordStore.config_path = options.fetch('config', "#{Dir.pwd}/config.yml")
8
+ end
9
+
10
+ desc 'thaw', 'Thaws all zones under management to allow manual edits'
11
+ def thaw
12
+ Zone.each do |_, zone|
13
+ zone.provider.thaw if zone.provider.thawable?
14
+ end
15
+ end
16
+
17
+ desc 'freeze', 'Freezes all zones under management to prevent manual edits'
18
+ def freeze
19
+ Zone.each do |_, zone|
20
+ zone.provider.freeze_zone if zone.provider.freezable?
21
+ end
22
+ end
23
+
24
+ desc 'list', 'Lists out records in YAML zonefiles'
25
+ def list
26
+ Zone.each do |name, zone|
27
+ puts "Zone: #{name}"
28
+ zone.records.each do |record|
29
+ record.log!
30
+ end
31
+ end
32
+ end
33
+
34
+ option :verbose, desc: 'Print records that haven\'t diverged', aliases: '-v', type: :boolean, default: false
35
+ desc 'diff', 'Displays the DNS differences between the zone files in this repo and production'
36
+ def diff
37
+ Zone.each do |name, zone|
38
+ puts "Zone: #{name}"
39
+
40
+ diff = zone.changeset
41
+
42
+ if !diff.additions.empty? || options.fetch('verbose')
43
+ puts "Add:"
44
+ diff.additions.map(&:record).each do |record|
45
+ puts " - #{record.to_s}"
46
+ end
47
+ end
48
+
49
+ if !diff.removals.empty? || options.fetch('verbose')
50
+ puts "Remove:"
51
+ diff.removals.map(&:record).each do |record|
52
+ puts " - #{record.to_s}"
53
+ end
54
+ end
55
+
56
+ if !diff.updates.empty? || options.fetch('verbose')
57
+ puts "Update:"
58
+ diff.updates.map(&:record).each do |record|
59
+ puts " - #{record.to_s}"
60
+ end
61
+ end
62
+
63
+ if options.fetch('verbose')
64
+ puts "Unchanged:"
65
+ diff.unchanged.each do |record|
66
+ puts " - #{record.to_s}"
67
+ end
68
+ end
69
+
70
+ puts "Empty diff" if diff.changes.empty?
71
+ puts '=' * 20
72
+ end
73
+ end
74
+
75
+ desc 'apply', 'Applies the DNS changes'
76
+ def apply
77
+ zones = zones_modified
78
+
79
+ if zones.empty?
80
+ puts "No changes to sync"
81
+ exit
82
+ end
83
+
84
+ zones.each do |zone|
85
+ abort "Attempted to apply invalid zone: #{zone.name}" unless zone.valid?
86
+ provider = zone.provider
87
+ provider.apply_changeset(zone.changeset)
88
+ end
89
+
90
+ puts "All zone changes deployed"
91
+ end
92
+
93
+ option :name, desc: 'Zone to download', aliases: '-n', type: :string, required: true
94
+ option :provider, desc: 'Provider in which this zone exists', aliases: '-p', type: :string
95
+ desc 'download', 'Downloads all records from zone and creates YAML zone definition in zones/ e.g. record-store download --name=shopify.io'
96
+ def download
97
+ name = options.fetch('name')
98
+ abort 'Please omit the period at the end of the zone' if name.ends_with?('.')
99
+ abort 'Zone with this name already exists in zones/' if File.exists?("#{RecordStore.zones_path}/#{name}.yml")
100
+
101
+ provider = options.fetch('provider', Provider.provider_for(name))
102
+ if provider.nil?
103
+ puts "Could not find valid provider from #{name} SOA record"
104
+ provider = ask("Please enter the provider in which #{name} exists")
105
+ else
106
+ puts "Identified #{provider} as the DNS provider"
107
+ end
108
+
109
+ puts "Downloading records for #{name}"
110
+ Zone.download(name, provider)
111
+ puts "Records have been downloaded & can be found in zones/#{name}.yml"
112
+ end
113
+
114
+ option :name, desc: 'Zone to sort', aliases: '-n', type: :string, required: true
115
+ desc 'sort', 'Sorts the zonefile alphabetically e.g. record-store sort --name=shopify.io'
116
+ def sort
117
+ name = options.fetch('name')
118
+ abort "Please omit the period at the end of the zone" if name.ends_with?('.')
119
+
120
+ yaml = YAML.load_file("#{RecordStore.zones_path}/#{name}.yml")
121
+ yaml.fetch(name).fetch('records').sort_by! { |r| [r.fetch('fqdn'), r.fetch('type'), r['nsdname'] || r['address']] }
122
+
123
+ File.write("#{RecordStore.zones_path}/#{name}.yml", yaml.deep_stringify_keys.to_yaml.gsub("---\n", ''))
124
+ end
125
+
126
+ desc 'secrets', 'Decrypts DynECT credentials'
127
+ def secrets
128
+ environment = begin
129
+ if ENV['PRODUCTION']
130
+ 'production'
131
+ elsif ENV['CI']
132
+ 'ci'
133
+ else
134
+ 'dev'
135
+ end
136
+ end
137
+
138
+ secrets = `ejson decrypt #{RecordStore.secrets_path.sub(/\.json\z/, ".#{environment}.ejson")}`
139
+ if $?.exitstatus == 0
140
+ File.write(RecordStore.secrets_path, secrets)
141
+ else
142
+ abort secrets
143
+ end
144
+ end
145
+
146
+ desc 'assert_empty_diff', 'Asserts there is no divergence between DynECT & the zone files'
147
+ def assert_empty_diff
148
+ zones = zones_modified.map(&:name)
149
+
150
+ unless zones.empty?
151
+ abort "The following zones have diverged: #{zones.join(', ')}"
152
+ end
153
+ end
154
+
155
+ desc 'validate_records', 'Validates that all DNS records have valid definitions'
156
+ def validate_records
157
+ invalid_zones = []
158
+ Zone.each do |name, zone|
159
+ if !zone.valid?
160
+ invalid_zones << name
161
+
162
+ puts "#{name} definition is not valid:"
163
+ zone.errors.each do |field, msg|
164
+ puts " - #{field}: #{msg}"
165
+ end
166
+
167
+ invalid_records = zone.records.reject(&:valid?)
168
+ puts ' Invalid records' if invalid_records.size > 0
169
+
170
+ invalid_records.each do |record|
171
+ puts " #{record.to_s}"
172
+ record.errors.each do |field, msg|
173
+ puts " - #{field}: #{msg}"
174
+ end
175
+ end
176
+ end
177
+ end
178
+
179
+ if invalid_zones.size > 0
180
+ abort "The following zones were invalid: #{invalid_zones.join(', ')}"
181
+ else
182
+ puts "All records have valid definitions."
183
+ end
184
+ end
185
+
186
+ desc 'validate_change_size', "Validates no more then particular limit of DNS records are removed per zone at a time"
187
+ def validate_change_size
188
+ zones = zones_modified
189
+
190
+ unless zones.empty?
191
+ removals = zones.select do |zone|
192
+ zone.changeset.removals.size > MAXIMUM_REMOVALS
193
+ end
194
+
195
+ unless removals.empty?
196
+ abort "As a safety measure, you cannot remove more than #{MAXIMUM_REMOVALS} records at a time per zone. (zones failing this: #{removals.map(&:name).join(', ')})"
197
+ end
198
+ end
199
+ end
200
+
201
+ desc 'validate_all_present', 'Validates that all the zones that are expected are defined'
202
+ def validate_all_present
203
+ defined_zones = Set.new(RecordStore.defined_zones)
204
+ expected_zones = Set.new(RecordStore.expected_zones)
205
+
206
+ missing_zones = expected_zones - defined_zones
207
+ extra_zones = defined_zones - expected_zones
208
+
209
+ unless missing_zones.empty?
210
+ abort "The following zones are missing: #{missing_zones.to_a.join(', ')}"
211
+ end
212
+
213
+ unless extra_zones.empty?
214
+ abort "The following unexpected zones are defined: #{extra_zones.to_a.join(', ')}"
215
+ end
216
+ end
217
+
218
+ SKIP_CHECKS = 'SKIP_DEPLOY_VALIDATIONS'
219
+ desc 'validate_initial_state', "Validates state hasn't diverged since the last deploy"
220
+ def validate_initial_state
221
+ begin
222
+ assert_empty_diff
223
+ puts "Deploy will cause no changes, no need to validate initial state"
224
+ rescue SystemExit
225
+ if File.exists?(File.expand_path(SKIP_CHECKS, Dir.pwd))
226
+ puts "Found '#{SKIP_CHECKS}', skipping predeploy validations"
227
+ else
228
+ puts "Checkout git SHA #{ENV['LAST_DEPLOYED_SHA']}"
229
+ `git checkout #{ENV['LAST_DEPLOYED_SHA']}`
230
+ abort "Checkout of old commit failed" if $?.exitstatus != 0
231
+
232
+ `record-store assert_empty_diff`
233
+ abort "Dyn status has diverged!" if $?.exitstatus != 0
234
+
235
+ puts "Checkout git SHA #{ENV['REVISION']}"
236
+ `git checkout #{ENV['REVISION']}`
237
+ abort "Checkout of new commit failed" if $?.exitstatus != 0
238
+ end
239
+ end
240
+ end
241
+
242
+ private
243
+
244
+ def zones_modified
245
+ modified_zones, mutex = [], Mutex.new
246
+ Zone.all.map do |zone|
247
+ thread = Thread.new do
248
+ mutex.synchronize {modified_zones << zone} unless zone.unchanged?
249
+ end
250
+ end.each(&:join)
251
+
252
+ modified_zones
253
+ end
254
+ end
255
+ end