discourse/lib/temporary_db.rb
Jarek Radosz 0092d481f3
DEV: Handle running TemporaryDb as root (#38988)
PostgreSQL refuses to run initdb/pg_ctl as root. When TemporaryDb
detects it is running as root, it now delegates all PG commands to
the postgres system user via sudo. This fixes the migration-tests
CI workflow where the schema validation step runs as root.

Also adds a PATH-based fallback for pg_bin_path discovery to support
distributions that install PG binaries outside the hardcoded paths
(e.g. Fedora system packages in /usr/bin).

---------

Co-authored-by: Gerhard Schlager <gerhard.schlager@discourse.org>
2026-03-31 20:17:50 +02:00

234 lines
5.8 KiB
Ruby

# frozen_string_literal: true
require "open3"
require "securerandom"
require "tmpdir"
class TemporaryDb
PG_TEMP_PREFIX = "pg_schema_tmp"
VERSIONS = 10..30 # arbitrary upper limit to avoid updating this code for a long time
STARTUP_TIMEOUT_SECONDS = 60
DEFAULT_PG_SYSTEM_USER = "postgres"
def initialize(pg_system_user: DEFAULT_PG_SYSTEM_USER)
@pg_temp_path = File.join(Dir.tmpdir, "#{PG_TEMP_PREFIX}_#{SecureRandom.hex(6)}")
@pg_conf = "#{@pg_temp_path}/postgresql.conf"
@pg_sock_path = "#{@pg_temp_path}/sockets"
@pg_system_user = pg_system_user
end
def port_available?(port)
TCPServer.open(port).close
true
rescue Errno::EADDRINUSE
false
end
def pg_bin_path
return @pg_bin_path if @pg_bin_path
# Debian/Ubuntu: /usr/lib/postgresql/{version}/bin
VERSIONS.reverse_each do |v|
bin_path = "/usr/lib/postgresql/#{v}/bin"
return @pg_bin_path = bin_path if File.exist?("#{bin_path}/pg_ctl")
end
# RHEL/Fedora (PGDG): /usr/pgsql-{version}/bin
VERSIONS.reverse_each do |v|
bin_path = "/usr/pgsql-#{v}/bin"
return @pg_bin_path = bin_path if File.exist?("#{bin_path}/pg_ctl")
end
# macOS Postgres.app
bin_path = "/Applications/Postgres.app/Contents/Versions/latest/bin"
return @pg_bin_path = bin_path if File.exist?("#{bin_path}/pg_ctl")
# Fallback: check if pg_ctl is on PATH (e.g. Fedora system packages install to /usr/bin)
pg_ctl = `which pg_ctl 2>/dev/null`.strip
return @pg_bin_path = File.dirname(pg_ctl) if pg_ctl.present?
raise "Cannot find pg_ctl. Install the PostgreSQL server package."
end
def initdb_path
@initdb_path ||= "#{pg_bin_path}/initdb"
end
def find_free_port(range)
range.each { |port| return port if port_available?(port) }
end
def pg_port
@pg_port ||= find_free_port(11_000..11_900)
end
def pg_ctl_path
@pg_ctl_path ||= "#{pg_bin_path}/pg_ctl"
end
def start
init_data_directory
configure_ports
puts "Starting postgres on port: #{pg_port}"
@previous_discourse_pg_port = ENV["DISCOURSE_PG_PORT"]
ENV["DISCOURSE_PG_PORT"] = pg_port.to_s
start_server
@started = true
create_user
create_database
puts "PG server is ready and DB is loaded"
rescue StandardError
restore_discourse_pg_port
raise
end
def stop
@started = false
args = [pg_ctl_path, "-D", @pg_temp_path, "stop"]
args = ["sudo", "-u", @pg_system_user, *args] if running_as_root?
Open3.capture3(*args)
ensure
restore_discourse_pg_port
end
def connection_hash
{ adapter: "postgresql", database: "discourse", port: pg_port, host: "localhost" }
end
def with_env(&block)
old_host = ENV["PGHOST"]
old_user = ENV["PGUSER"]
old_port = ENV["PGPORT"]
old_dev_db = ENV["DISCOURSE_DEV_DB"]
old_rails_db = ENV["RAILS_DB"]
ENV["PGHOST"] = "localhost"
ENV["PGUSER"] = "discourse"
ENV["PGPORT"] = pg_port.to_s
ENV["DISCOURSE_DEV_DB"] = "discourse"
ENV["RAILS_DB"] = "discourse"
yield
ensure
ENV["PGHOST"] = old_host
ENV["PGUSER"] = old_user
ENV["PGPORT"] = old_port
ENV["DISCOURSE_DEV_DB"] = old_dev_db
ENV["RAILS_DB"] = old_rails_db
end
def remove
raise "Error: the database must be stopped before it can be removed" if @started
FileUtils.rm_rf @pg_temp_path
end
def migrate
raise "Error: the database must be started before it can be migrated." if !@started
ActiveRecord::Base.establish_connection(connection_hash)
puts "Running migrations on blank database!"
old_stdout = $stdout.clone
old_stderr = $stderr.clone
$stdout.reopen(File.new("/dev/null", "w"))
$stderr.reopen(File.new("/dev/null", "w"))
SeedFu.quiet = true
Rake::Task["db:migrate"].invoke
ensure
$stdout.reopen(old_stdout) if old_stdout
$stderr.reopen(old_stderr) if old_stderr
end
private
def init_data_directory
FileUtils.rm_rf @pg_temp_path
run_command!(
initdb_path,
"-D",
@pg_temp_path,
"--auth-host=trust",
"--locale=en_US.UTF-8",
"-E",
"UTF8",
error_prefix: "Failed to initialize postgres data directory",
)
end
def configure_ports
FileUtils.mkdir(@pg_sock_path)
FileUtils.chown(@pg_system_user, nil, @pg_sock_path) if running_as_root?
conf = File.read(@pg_conf)
File.write(@pg_conf, conf + "\nport = #{pg_port}\nunix_socket_directories = '#{@pg_sock_path}'")
end
def start_server
log_file = File.join(@pg_temp_path, "server.log")
run_command!(
pg_ctl_path,
"-D",
@pg_temp_path,
"-l",
log_file,
"-w",
"-t",
STARTUP_TIMEOUT_SECONDS.to_s,
"start",
error_prefix: "Failed to start postgres within #{STARTUP_TIMEOUT_SECONDS}s",
)
end
def create_user
run_command!(
"createuser",
"-h",
"localhost",
"-p",
pg_port.to_s,
"-s",
"-D",
"-w",
"discourse",
error_prefix: "Failed to create temporary postgres superuser",
)
end
def create_database
run_command!(
"createdb",
"-h",
"localhost",
"-p",
pg_port.to_s,
"discourse",
error_prefix: "Failed to create temporary postgres database",
)
end
def run_command!(*args, error_prefix:)
args = ["sudo", "-u", @pg_system_user, *args] if running_as_root?
stdout, stderr, status = Open3.capture3(*args)
return if status.success?
details = stderr.to_s.strip
details = stdout.to_s.strip if details.empty?
details = "unknown error" if details.empty?
raise "#{error_prefix}: #{details}"
rescue Errno::ENOENT => e
raise "#{error_prefix}: #{e.message}"
end
def running_as_root?
Process.uid == 0
end
def restore_discourse_pg_port
ENV["DISCOURSE_PG_PORT"] = @previous_discourse_pg_port
@previous_discourse_pg_port = nil
end
end