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