require 'fileutils' require 'open3' require 'tempfile' module Geordi class DBCleaner def initialize(extra_flags) puts 'Please enter your sudo password if asked, for db operations as system users' puts "We're going to run `sudo -u postgres psql` for PostgreSQL" puts ' and `sudo mysql` for MariaDB (which uses PAM auth)' `sudo true` Interaction.fail 'sudo access is required for database operations as database users' if $? != 0 @derivative_dbname = /_(test\d*|development|cucumber)$/ base_directory = ENV['XDG_CONFIG_HOME'] base_directory = Dir.home.to_s if base_directory.nil? @whitelist_directory = File.join(base_directory, '.config', 'geordi', 'whitelists') FileUtils.mkdir_p(@whitelist_directory) unless File.directory? @whitelist_directory @mysql_command = decide_mysql_command(extra_flags['mysql']) @postgres_command = decide_postgres_command(extra_flags['postgres']) end def edit_whitelist(dbtype) whitelist = whitelist_fname(dbtype) whitelisted_dbs = if File.exist? whitelist Geordi::Util.stripped_lines(File.read(whitelist))\ .delete_if { |l| l.start_with? '#' } else [] end all_dbs = list_all_dbs(dbtype) tmp = Tempfile.open("geordi_whitelist_#{dbtype}") tmp.write <<~HEREDOC # Put each whitelisted database on a new line. # System databases will never be deleted. # When you whitelist foo, foo_development and foo_test\\d* are whitelisted, too. # This works even if foo does not exist. Also, you will only see foo in this list. # # Syntax: keep foo # drop bar HEREDOC tmpfile_content = Array.new all_dbs.each do |db| next if is_whitelisted?(dbtype, db) next if is_protected?(dbtype, db) db.sub!(@derivative_dbname, '') tmpfile_content.push(['drop', db]) end warn_manual_whitelist = false whitelisted_dbs.each do |db_name| # Remove 'keep' word from whitelist entries. This is not normally required since geordi # does not save 'keep' or 'drop' to the whitelist file on disk but rather saves a list # of all whitelisted db names and just presents the keep/drop information while editing # the whitelist to supply users a list of databases they can whitelist by changing the # prefix to 'keep'. Everything prefixed 'drop' is not considered whitelisted and thus # not written to the whitelist file on disk. # # However, if users manually edit their whitelist files they might use the keep/drop # syntax they're familiar with. if db_name.start_with? 'keep ' db_name.gsub!(/keep /, '') db_name = db_name.split[1..-1].join(' ') warn_manual_whitelist = true end tmpfile_content.push(['keep', db_name]) unless db_name.empty? end if warn_manual_whitelist Interaction.warn <<~ERROR_MSG Your whitelist #{whitelist} seems to have been generated manually. In that case, make sure to use only one database name per line and omit the 'keep' prefix." Launching the editor. ERROR_MSG end tmpfile_content.sort_by! { |k| k[1] } tmpfile_content.uniq! tmpfile_content.each do |line| tmp.write("#{line[0]} #{line[1]}\n") end tmp.close texteditor = Geordi::Util.decide_texteditor system("#{texteditor} #{tmp.path}") File.open(tmp.path, 'r') do |wl_edited| whitelisted_dbs = [] whitelist_storage = File.open(whitelist, 'w') lines = Geordi::Util.stripped_lines(wl_edited.read) lines.each do |line| next if line.start_with?('#') unless line.split.length == 2 Interaction.fail "Invalid edit to whitelist file: \`#{line}\` - Syntax is: ^[keep|drop] dbname$" end unless %w[keep drop k d].include? line.split.first Interaction.fail "Invalid edit to whitelist file: \`#{line}\` - must start with either drop or keep." end db_status, db_name = line.split if db_status == 'keep' whitelisted_dbs.push db_name whitelist_storage.write(db_name << "\n") end end whitelist_storage.close end end def decide_mysql_command(extra_flags) cmd = 'sudo mysql' unless extra_flags.nil? if extra_flags.include? 'port' port = Integer(extra_flags.split('=')[1].split[0]) Interaction.fail "Port #{port} is not open" unless Geordi::Util.is_port_open? port end cmd << " #{extra_flags}" end Open3.popen3("#{cmd} -e 'QUIT'") do |_stdin, _stdout, stderr, thread| break if thread.value.exitstatus == 0 # sudo mysql was not successful, switching to mysql-internal user management mysql_error = stderr.read.lines[0].chomp.strip.split[1] if %w[1045 1698].include? mysql_error # authentication failed cmd = 'mysql -uroot' cmd << " #{extra_flags}" unless extra_flags.nil? unless File.exist? File.join(Dir.home, '.my.cnf') puts "Please enter your MySQL/MariaDB password for account 'root'." Interaction.warn "You should create a ~/.my.cnf file instead, or you'll need to enter your MySQL root password for each db." Interaction.warn 'See https://makandracards.com/makandra/50813-store-mysql-passwords-for-development for more information.' cmd << ' -p' # need to ask for password now end Open3.popen3("#{cmd} -e 'QUIT'") do |_stdin_2, _stdout_2, _stderr_2, thread_2| Interaction.fail 'Could not connect to MySQL/MariaDB' unless thread_2.value.exitstatus == 0 end elsif mysql_error == '2013' # connection to port or socket failed Interaction.fail 'MySQL/MariaDB connection failed, is this the correct port?' end end cmd end private :decide_mysql_command def decide_postgres_command(extra_flags) cmd = 'sudo -u postgres psql' unless extra_flags.nil? begin port = Integer(extra_flags.split('=')[1]) Interaction.fail "Port #{port} is not open" unless Geordi::Util.is_port_open? port rescue ArgumentError socket = extra_flags.split('=')[1] Interaction.fail "Socket #{socket} does not exist" unless File.exist? socket end cmd << " #{extra_flags}" end cmd end private :decide_postgres_command def list_all_dbs(dbtype) if dbtype == 'postgres' list_all_postgres_dbs else list_all_mysql_dbs end end def list_all_postgres_dbs `#{@postgres_command} -t -A -c 'SELECT DATNAME FROM pg_database WHERE datistemplate = false'`.split end def list_all_mysql_dbs if @mysql_command.include? '-p' puts "Please enter your MySQL/MariaDB account 'root' for: list all databases" end `#{@mysql_command} -B -N -e 'show databases'`.split end def clean_mysql Interaction.announce 'Checking for MySQL databases' database_list = list_all_dbs('mysql') # confirm_deletion includes option for whitelist editing deletable_dbs = confirm_deletion('mysql', database_list) return if deletable_dbs.nil? deletable_dbs.each do |db| if @mysql_command.include? '-p' puts "Please enter your MySQL/MariaDB account 'root' for: DROP DATABASE #{db}" else puts "Dropping MySQL/MariaDB database #{db}" end `#{@mysql_command} -e 'DROP DATABASE \`#{db}\`;'` end end def clean_postgres Interaction.announce 'Checking for Postgres databases' database_list = list_all_dbs('postgres') deletable_dbs = confirm_deletion('postgres', database_list) return if deletable_dbs.nil? deletable_dbs.each do |db| Interaction.note "Dropping PostgreSQL database `#{db}`." `#{@postgres_command} -c 'DROP DATABASE "#{db}";'` end end def whitelist_fname(dbtype) File.join(@whitelist_directory, dbtype) << '.txt' end def confirm_deletion(dbtype, database_list) proceed = '' until %w[y n].include? proceed deletable_dbs = filter_whitelisted(dbtype, database_list) if deletable_dbs.empty? Interaction.note "No #{dbtype} databases found that were not whitelisted." if Interaction.prompt('Edit the whitelist? [y]es or [n]o') == 'y' proceed = 'e' else return [] end end if proceed.empty? Interaction.note "The following #{dbtype} databases are not whitelisted and can be deleted:" deletable_dbs.sort.each do |db| puts db end Interaction.note "These #{dbtype} databases are not whitelisted and can be deleted." proceed = Interaction.prompt('Proceed? [y]es, [n]o or [e]dit whitelist') end case proceed when 'e' proceed = '' # reset user selection edit_whitelist dbtype when 'n' Interaction.success 'Nothing deleted.' return [] when 'y' return deletable_dbs end end end private :confirm_deletion def is_protected?(dbtype, database_name) protected = { 'mysql' => %w[mysql information_schema performance_schema sys], 'postgres' => ['postgres'], } protected[dbtype].include? database_name end def is_whitelisted?(dbtype, database_name) whitelist_content = if File.exist? whitelist_fname(dbtype) Geordi::Util.stripped_lines(File.open(whitelist_fname(dbtype), 'r').read) else [] end # Allow explicit whitelisting of derivative databases like projectname_test2 if whitelist_content.include? database_name true # whitelisting `projectname` also whitelists `projectname_test\d*`, `projectname_development` elsif whitelist_content.include? database_name.sub(@derivative_dbname, '') true else false end end def filter_whitelisted(dbtype, database_list) # n.b. `delete` means 'delete from list of dbs that should be deleted in this context # i.e. `delete` means 'keep this database' deletable_dbs = database_list.dup deletable_dbs.delete_if { |db| is_whitelisted?(dbtype, db) if File.exist? whitelist_fname(dbtype) } deletable_dbs.delete_if { |db| is_protected?(dbtype, db) } deletable_dbs.delete_if { |db| db.start_with? '#' } end private :filter_whitelisted end end