/
cli.rb
360 lines (297 loc) · 11.2 KB
/
cli.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
require 'English'
module RecordStore
class CLI < Thor
class_option :config, desc: 'Path to config.yml', aliases: '-c'
FORMATS = %w[file directory]
def initialize(*args)
super
RecordStore.config_path = options.fetch('config', "#{Dir.pwd}/config.yml")
end
class << self
def exit_on_failure?
true
end
end
desc 'thaw', 'Thaws all zones under management to allow manual edits'
def thaw
Zone.each do |_, zone|
zone.providers.each do |provider|
provider.thaw_zone(zone.unrooted_name) if provider.thawable?
end
end
end
desc 'freeze', 'Freezes all zones under management to prevent manual edits'
def freeze
Zone.each do |_, zone|
zone.providers.each do |provider|
provider.freeze_zone(zone.unrooted_name) if provider.freezable?
end
end
end
desc 'info', 'Show information about zones under management'
option :delegation, desc: 'Include delegation', aliases: '-d', type: :boolean, default: false
def info
Zone.each do |name, zone|
puts "\n"
puts "Zone: #{name}"
puts "Providers:"
zone.config.providers.each { |p| puts "- #{p}" }
if (delegation = zone.fetch_authority)
puts "Authoritative nameservers:"
delegation.each { |d| puts "- #{d}" }
else
$stderr.puts "ERROR: Unable to determine delegation (#{name})"
end
end
end
desc 'list', 'Lists out records in YAML zonefiles'
option :all, desc: 'Show all records', aliases: '-a', type: :boolean, default: false
def list
Zone.each do |name, zone|
puts "Zone: #{name}"
records = options.fetch('all') ? zone.all : zone.records
records.each(&:log!)
end
end
desc 'diff [ZONE ...]', 'Displays the DNS differences between the zone files in this repo and production'
option :all, desc: 'Include all records', aliases: '-a', type: :boolean, default: false
option :verbose, desc: 'Print records that haven\'t diverged', aliases: '-v', type: :boolean, default: false
def diff(*zones)
puts "Diffing #{zones.any? ? zones.count : Zone.defined.count} zone(s)"
all = options.fetch('all')
Zone.each do |name, zone|
next unless zones.empty? || zones.include?(name)
changesets = zone.build_changesets(all: all)
if !options.fetch('verbose') && changesets.all?(&:empty?)
print_and_flush('.')
next
else
puts "\n"
puts "Zone: #{name}"
end
changesets.each do |changeset|
next if !options.fetch('verbose') && changeset.changes.empty?
puts '-' * 20
puts "Provider: #{changeset.provider}"
if changeset.additions.any?
puts "Add:"
changeset.additions.map(&:record).each do |record|
puts " - #{record}"
end
end
if changeset.removals.any?
puts "Remove:"
changeset.removals.map(&:record).each do |record|
puts " - #{record}"
end
end
if changeset.updates.any?
puts "Update:"
changeset.updates.map(&:record).each do |record|
puts " - #{record}"
end
end
next unless options.fetch('verbose')
puts "Unchanged:"
changeset.unchanged.each do |record|
puts " - #{record}"
end
end
puts '=' * 20
end
puts "\n"
end
desc 'apply', 'Applies the DNS changes'
def apply
zones = Zone.modified
if zones.empty?
puts "No changes to sync"
exit
end
invalid_zones = zones.select(&:invalid?)
unless invalid_zones.empty?
error_message = invalid_zones
.map { |z| "Attempted to apply invalid zone: #{z.name}: #{z.errors.full_messages.join(',')}" }
.join("\n")
abort(error_message)
end
zones.each do |zone|
changesets = zone.build_changesets
changesets.each(&:apply)
end
puts "All zone changes deployed"
end
option :name, desc: 'Zone to download', aliases: '-n', type: :string, required: true
option :provider, desc: 'Provider in which this zone exists', aliases: '-p', type: :string
desc 'download', 'Downloads all records from zone and creates YAML zone definition in zones/ ' \
'e.g. record-store download --name=shopify.io'
def download
name = options.fetch('name')
abort('Please omit the period at the end of the zone') if name.ends_with?('.')
abort('Zone with this name already exists in zones/') if File.exist?("#{RecordStore.zones_path}/#{name}.yml")
provider = options.fetch('provider', Provider.provider_for(name))
if provider.nil?
puts "Could not find valid provider from #{name} SOA record"
provider = ask("Please enter the provider in which #{name} exists")
else
puts "Identified #{provider} as the DNS provider"
end
puts "Downloading records for #{name}"
Zone.download(name, provider)
puts "Records have been downloaded & can be found in zones/#{name}.yml"
end
option :name, desc: 'Zone to reformat', aliases: '-n', type: :string, required: false
desc 'reformat', 'Sorts and re-outputs the zone (or all zones) as specified format (file)'
def reformat
name = options['name']
zones = name ? [Zone.find(name)] : Zone.all
zones.each do |zone|
puts "Writing #{zone.name}"
zone.write
end
end
option :name, desc: 'Zone to sort', aliases: '-n', type: :string, required: true
desc 'sort', 'Sorts the zonefile alphabetically e.g. record-store sort --name=shopify.io'
def sort
name = options.fetch('name')
abort("Please omit the period at the end of the zone") if name.ends_with?('.')
yaml = YAML.load_file("#{RecordStore.zones_path}/#{name}.yml")
yaml.fetch(name).fetch('records').sort_by! do |r|
[r.fetch('fqdn'), r.fetch('type'), r['nsdname'] || r['address']]
end
File.write("#{RecordStore.zones_path}/#{name}.yml", yaml.deep_stringify_keys.to_yaml.gsub("---\n", ''))
end
desc 'secrets', 'Decrypts DynECT credentials'
def secrets
environment = if ENV['PRODUCTION']
'production'
elsif ENV['CI']
'ci'
else
'dev'
end
secrets = %x(ejson decrypt #{RecordStore.secrets_path.sub(/\.json\z/, ".#{environment}.ejson")})
if $CHILD_STATUS.success?
File.write(RecordStore.secrets_path, secrets)
else
abort(secrets)
end
end
desc 'assert_empty_diff', 'Asserts there is no divergence between DynECT & the zone files'
def assert_empty_diff
zones = Zone.modified.map(&:name)
unless zones.empty?
abort("The following zones have diverged: #{zones.join(', ')}")
end
end
desc 'validate_authority [ZONE ...]', 'Validates that authoritative nameservers match the providers'
option :verbose, desc: 'Include valid zones in output', aliases: '-v', type: :boolean, default: false
def validate_authority(*zones)
verbose = options.fetch('verbose')
Zone.each do |name, zone|
next unless zones.empty? || zones.include?(name)
authority = zone.fetch_authority
delegation = Hash.new { |h, k| h[k] = [] }
authority.each do |ns|
delegation[Provider.provider_for(ns)] << ns
end
delegated = delegation.keys.sort
configured = zone.config.providers.sort
ok = configured & delegated
missing = configured - delegated
unconfigured = delegated - configured
next if !verbose && missing.empty? && unconfigured.empty?
puts "\n"
puts "Zone: #{name}"
if verbose
ok.each do |provider|
puts "- #{provider}:"
delegation[provider].each do |ns|
puts " - #{ns.nsdname}"
end
end
end
missing.each do |provider|
puts "- #{provider}: authoritative nameservers not found for configured provider"
end
unconfigured.each do |provider|
if provider
puts "- #{provider}: unexpected authoritative nameservers found"
else
puts "- Unknown: unknown authoritative nameservers found"
end
delegation[provider].each do |ns|
puts " - #{ns.nsdname}"
end
end
end
end
desc 'validate_records', 'Validates that all DNS records have valid definitions'
def validate_records
invalid_zones = []
Zone.all.reject(&:valid?).each do |zone|
invalid_zones << zone.unrooted_name
puts "#{zone.unrooted_name} definition is not valid:"
puts zone.errors.full_messages.map { |msg| " - #{msg}" }
invalid_records = zone.records.reject(&:valid?)
puts ' Invalid records' unless invalid_records.empty?
invalid_records.each do |record|
puts " #{record}"
record.errors.messages.each do |field, errors|
errors.each do |msg|
puts " - #{field}: #{msg}"
end
end
end
end
if invalid_zones.present?
abort("The following zones were invalid: #{invalid_zones.join(', ')}")
else
puts "All zones have valid definitions."
end
end
desc 'validate_change_size', "Validates no more then particular limit of DNS records are removed per zone at a time"
def validate_change_size
zones = Zone.modified
unless zones.empty?
removals = zones.select do |zone|
zone.changeset.removals.size > MAXIMUM_REMOVALS
end
unless removals.empty?
error = +"As a safety measure, you cannot remove more than #{MAXIMUM_REMOVALS} "
error << 'records at a time per zone. '
error << "(zones failing this: #{removals.map(&:name).join(', ')})"
abort(error)
end
end
end
SKIP_CHECKS = 'SKIP_DEPLOY_VALIDATIONS'
desc 'validate_initial_state', "Validates state hasn't diverged since the last deploy"
def validate_initial_state
assert_empty_diff
puts "Deploy will cause no changes, no need to validate initial state"
rescue SystemExit
if File.exist?(File.expand_path(SKIP_CHECKS, Dir.pwd))
puts "Found '#{SKIP_CHECKS}', skipping predeploy validations"
else
puts "Checkout git SHA #{ENV['LAST_DEPLOYED_SHA']}"
%x(git checkout #{ENV['LAST_DEPLOYED_SHA']})
abort("Checkout of old commit failed") unless $CHILD_STATUS.success?
%x(record-store secrets)
abort("Decrypt secrets failed") unless $CHILD_STATUS.success?
%x(record-store assert_empty_diff)
abort("Dyn status has diverged!") unless $CHILD_STATUS.success?
puts "Checkout git SHA #{ENV['REVISION']}"
%x(git checkout #{ENV['REVISION']})
abort("Checkout of new commit failed") unless $CHILD_STATUS.success?
%x(record-store secrets)
abort("Decrypt secrets failed") unless $CHILD_STATUS.success?
end
end
private
def print_and_flush(str)
print(str)
$stdout.flush
end
end
end