lib/gdk/postgresql_upgrader.rb (217 lines of code) (raw):

# frozen_string_literal: true require 'fileutils' require 'forwardable' require 'json' require_relative 'postgresql' module GDK class PostgresqlUpgrader extend Forwardable POSTGRESQL_VERSIONS = %w[16 14 13 12 11 10 9.6].freeze def_delegators :postgresql, :current_data_dir, :current_version, :upgrade_needed? def initialize(target_version = GDK::Postgresql.target_version_major) @target_version = target_version end def upgrade! check! unless upgrade_needed?(target_version) GDK::Output.success "'#{current_data_dir}' is already compatible with PostgreSQL #{target_version}." return end begin gdk_stop init_db_in_target_path pgvector_setup rename_current_data_dir pg_upgrade promote_new_db gdk_reconfigure pg_replica_upgrade('replica') pg_replica_upgrade('replica_2') rescue StandardError => e GDK::Output.error("An error occurred: #{e}", e) GDK::Output.warn 'Rolling back..' rename_current_data_dir_back GDK::Output.warn "Upgrade failed. Rolled back to the original PostgreSQL #{current_version}." raise e end GDK::Output.success "Upgraded '#{current_data_dir}' from PostgreSQL #{current_version} to #{target_version}." end def bin_path_or_fallback dir = begin bin_path rescue StandardError # Fallback to whatever pg_config is in the PATH pg_config_discover end unless dir GDK::Output.warn 'Unable to determine PostgreSQL bin directory. Using fallback.' return nil end dir end def bin_path(version = target_version) raise "Invalid PostgreSQL version #{version}" unless available_versions.key?(version) available_versions[version] end private attr_reader :target_version def check! GDK::Output.info "Available PostgreSQL versions: #{available_versions}" GDK::Output.abort "Unable to find target PostgreSQL version #{target_version}" unless available_versions.include?(target_version) GDK::Output.abort "Unable to find current PostgreSQL version #{current_version}" unless available_versions.include?(current_version) end def postgresql @postgresql ||= GDK::Postgresql.new end def renamed_current_data_dir @renamed_current_data_dir ||= "#{current_data_dir}.#{current_version}.#{Time.now.to_i}" end def target_path @target_path ||= "#{current_data_dir}.#{target_version}.#{Time.now.to_i}" end def gdk_stop run!('gdk stop', config.gdk_root) end def init_db_in_target_path cmd = "#{initdb_bin(target_version)} --locale=C -E utf-8 #{target_path}" GDK::Output.info "Initializing '#{target_path}' for PostgreSQL #{target_version}.." run_in_tmp!(cmd) end def rename_current_data_dir GDK::Output.info "Renaming #{current_data_dir} to #{renamed_current_data_dir}" FileUtils.mv(current_data_dir, renamed_current_data_dir) end def rename_current_data_dir_back return unless File.exist?(renamed_current_data_dir) GDK::Output.info "Renaming #{renamed_current_data_dir} to #{current_data_dir}" FileUtils.mv(renamed_current_data_dir, current_data_dir) end def pgvector_setup return unless config.pgvector.enabled? || config.gitlab.rails.databases.embedding.enabled? GDK::Output.info "Running 'make pgvector-clean pgvector-setup'.." run!('make pgvector-clean pgvector-setup', config.gdk_root) end def pg_upgrade cmd = "#{pg_upgrade_bin(target_version)} \ --old-bindir #{bin_path(current_version)} \ --new-bindir #{bin_path(target_version)} \ --old-datadir #{renamed_current_data_dir} \ --new-datadir #{target_path}" GDK::Output.info "Upgrading '#{renamed_current_data_dir}' (PostgreSQL #{current_version}) to '#{target_path}' PostgreSQL #{target_version}.." run_in_tmp!(cmd) end def remove_secondary_data?(replica_name) return false unless config.postgresql.public_send(replica_name).enabled? # rubocop:disable GitlabSecurity/PublicSend GDK::Output.warn("We're about to remove the old '#{replica_name}' database data because we will be replacing it with the primary database data.") return true if ENV.fetch('PG_AUTO_UPDATE', 'false') == 'true' || !GDK::Output.interactive? GDK::Output.prompt('Are you sure? [y/N]').match?(/\Ay(?:es)*\z/i) end def pg_replica_upgrade(replica_name) return true unless remove_secondary_data?(replica_name) pg_primary_dir = config.gdk_root.join('postgresql') pg_secondary_data_dir = config.gdk_root.join("postgresql-#{replica_name.tr('_', '-')}/data") GDK::Output.info 'Removing the old secondary database data...' run!("rm -rf #{pg_secondary_data_dir}", config.gdk_root) replication_user = config.postgresql.replication_user GDK::Output.info 'Copying data from primary to secondary...' cmd = "pg_basebackup -R -h #{pg_primary_dir} -D #{pg_secondary_data_dir} -P -U #{replication_user} --wal-method=fetch" run!(cmd, config.gdk_root) end def promote_new_db GDK::Output.info "Promoting newly-creating database from '#{target_path}' to '#{current_data_dir}'" FileUtils.mv(target_path, current_data_dir) end def gdk_reconfigure GDK::Output.info "Running 'gdk reconfigure'.." run!('gdk reconfigure', config.gdk_root) end def initdb_bin(version) File.join(bin_path(version), 'initdb') end def pg_upgrade_bin(version) File.join(bin_path(version), 'pg_upgrade') end def available_versions @available_versions ||= if GDK::Dependencies.asdf_available? asdf_available_versions elsif GDK::Dependencies.mise_available? mise_available_versions elsif GDK::Dependencies.homebrew_available? brew_cellar_available_versions.transform_keys(&:to_i) elsif GDK::Dependencies.linux_apt_available? apt_available_versions else raise 'Only Homebrew, asdf, mise, and apt based Linux systems supported.' end end def asdf_available_versions lines = run(%w[asdf list postgres]) return {} if lines.empty? current_asdf_data_dir = ENV.fetch('ASDF_DATA_DIR', "#{Dir.home}/.asdf") versions = lines.split.map { |x| Gem::Version.new(x) }.sort.reverse asdf_package_paths(current_asdf_data_dir, versions) end def mise_available_versions lines = run(%w[mise list --current --installed --json postgres]) return {} if lines.empty? JSON .parse(lines) .each { |x| x['version'] = Gem::Version.new(x.fetch('version')) } .sort_by { |x| x['version'] } .reverse .to_h { |x| [x.fetch('version').canonical_segments.first, x.fetch('install_path') << '/bin'] } end def asdf_package_paths(current_asdf_data_dir, versions) versions.each_with_object({}) do |version, paths| major_version, minor_version = version.canonical_segments[0..1] # We only care about the latest version next if paths.key?(major_version) paths[major_version] = "#{current_asdf_data_dir}/installs/postgres/#{major_version}.#{minor_version}/bin" end end def brew_cellar_available_versions POSTGRESQL_VERSIONS.each_with_object({}) do |version, paths| brew_cellar_pg = run(%W[brew --cellar postgresql@#{version}]) next if brew_cellar_pg.empty? brew_cellar_pg_bin = Dir.glob(File.join(brew_cellar_pg, '/*/bin')) paths[version] = brew_cellar_pg_bin.last if brew_cellar_pg_bin.any? end end def apt_available_versions versions = POSTGRESQL_VERSIONS.map { |ver| "postgresql-#{ver}" } lines = run(%w[apt search -o APT::Cache::Search::Version=1 ^postgresql-[0-9]*$]) return {} if lines.empty? available_packages = Set.new lines.split("\n").each do |line| package_data = line.strip.split available_packages << package_data.first.strip end postgresql_packages = available_packages & versions postgresql_packages.each_with_object({}) do |package, paths| version = package.gsub(/^postgresql-/, '').to_i pg_path = "/usr/lib/postgresql/#{version}/bin" paths[version] = pg_path if Dir.exist?(pg_path) end end def config GDK.config end def run(cmd) Shellout.new(cmd).try_run end def run!(cmd, chdir) sh = Shellout.new(cmd, chdir: chdir) sh.try_run return true if sh.success? GDK::Output.puts(sh.read_stdout) GDK::Output.puts(sh.read_stderr) raise "'#{cmd}' failed." end def run_in_tmp!(cmd) run!(cmd, config.gdk_root.join('tmp')) end def pg_config_discover Shellout.new(%w[pg_config --bindir], chdir: GDK.root).run rescue Errno::ENOENT nil end end end