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 def self.exit_on_failure? true 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 = begin if ENV['PRODUCTION'] 'production' elsif ENV['CI'] 'ci' else 'dev' end 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:" zone.errors.each do |field, msg| puts " - #{field}: #{msg}" end invalid_records = zone.records.reject(&:valid?) puts ' Invalid records' unless invalid_records.empty? invalid_records.each do |record| puts " #{record}" record.errors.each do |field, msg| puts " - #{field}: #{msg}" 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