mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2026-11-01 15:53:03 -06:00
evaluate_and_patch_config() called keyring.get_password() synchronously at `import config` time to detect a selected-but-unusable OS keyring backend. On Debian 13 (and similar headless/RDP sessions with no live D-Bus/GNOME-Keyring session), that call - and even the keyring import itself - can block forever, freezing the whole desktop app before it ever starts. Revert evaluate_config.py to the pre-9.17 synchronous check (backend name only, no get_password call). Move the usability probe into pgadmin.utils.keyring_probe, run from create_app() in a background daemon thread so it never delays startup, isolated in its own process via subprocess.Popen(sys.executable, '-c', ...) so a hang can actually be killed - a thread-only timeout can't do this, it would leave CPython's per-module import lock held forever and wedge any later `import keyring` in the parent process too. subprocess.Popen (plain fork+exec), not multiprocessing.Process, is required here: create_app() runs at the top level of pgAdmin4.py while it's still being imported, and multiprocessing's spawn start method refuses to start a child before the current process finishes bootstrapping its __main__ module. An earlier version of this fix used multiprocessing and crashed the probe thread with RuntimeError on every single test run, which corrupted the SQLAlchemy/sqlite session for the rest of app init and surfaced as an unrelated-looking "attempt to write a readonly database" error on module_preference inserts. config.USE_OS_SECRET_STORAGE is only ever read from request handlers requiring an authenticated session, never at import time or inside create_app() itself, so the async resolution race is safe in practice. Tests mock subprocess.Popen for the timeout/kill/config-fallback orchestration (deterministic, no real 3s wait or backend dependency), plus one test class that runs the real probe script in a real subprocess against a fake keyring module injected via PYTHONPATH, so the script body itself has real coverage. Verified with the full regression suite (--exclude feature_tests) in both desktop mode (2134 passed, 0 failed) and server mode (2251 passed, 0 failed); remaining skips are pre-existing pgAgent-dependent job tests.
168 lines
6.4 KiB
Python
168 lines
6.4 KiB
Python
##########################################################################
|
|
#
|
|
# pgAdmin 4 - PostgreSQL Tools
|
|
#
|
|
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
|
|
# This software is released under the PostgreSQL Licence
|
|
#
|
|
##########################################################################
|
|
|
|
import os
|
|
import sys
|
|
import importlib.util
|
|
|
|
from pgadmin.utils.db_utils import normalize_database_uri
|
|
|
|
# User configs loaded from config_local, config_distro etc.
|
|
custom_config_settings = {}
|
|
|
|
|
|
# Function to Extract settings from config_local, config_distro etc.
|
|
def get_variables_from_module(module_name):
|
|
module = globals().get(module_name, None)
|
|
variables = {}
|
|
if module:
|
|
variables = {key: value for key, value in module.__dict__.items()
|
|
if not (key.startswith('__') or key.startswith('_')) and
|
|
validate_config_variable(key, value)}
|
|
return variables
|
|
|
|
|
|
# Function to load config_distro at custom path
|
|
def import_module_from_path(module_name, file_path):
|
|
# Create a module spec
|
|
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
# Create the module based on the spec
|
|
module = importlib.util.module_from_spec(spec)
|
|
# Execute the module (this loads it)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def validate_config_variable(key, value):
|
|
boolean_keys = ['SERVER_MODE', 'ENHANCED_COOKIE_PROTECTION',
|
|
'SUPPORT_SSH_TUNNEL', 'ALLOW_SAVE_TUNNEL_PASSWORD',
|
|
'MASTER_PASSWORD_REQUIRED']
|
|
integer_keys = ['DEFAULT_SERVER_PORT', 'SERVER_HEARTBEAT_TIMEOUT',
|
|
'LOG_ROTATION_SIZE', 'LOG_ROTATION_AGE',
|
|
'LOG_ROTATION_MAX_LOG_FILES', 'MAX_SESSION_IDLE_TIME']
|
|
if key in boolean_keys and not isinstance(value, bool):
|
|
exception_msg = 'Expected boolean value for %s; got %r' % (key, value)
|
|
raise ValueError(exception_msg)
|
|
elif key in integer_keys and not isinstance(value, int):
|
|
exception_msg = 'Expected integer value for %s; got %r' % (key, value)
|
|
raise ValueError(exception_msg)
|
|
else:
|
|
# Do not validate
|
|
return True
|
|
|
|
|
|
# Load distribution-specific config overrides
|
|
try:
|
|
if 'CONFIG_DISTRO_FILE_PATH' in os.environ:
|
|
config_distro_path = os.environ['CONFIG_DISTRO_FILE_PATH']
|
|
config_distro = import_module_from_path('config_distro',
|
|
config_distro_path)
|
|
else:
|
|
import config_distro
|
|
config_distro_settings = get_variables_from_module('config_distro')
|
|
custom_config_settings.update(config_distro_settings)
|
|
except ImportError:
|
|
pass
|
|
|
|
# Load local config overrides
|
|
try:
|
|
import config_local
|
|
config_local_settings = get_variables_from_module('config_local')
|
|
custom_config_settings.update(config_local_settings)
|
|
except ImportError:
|
|
pass
|
|
|
|
# Load system config overrides. We do this last, so that the sysadmin can
|
|
# override anything they want from a config file that's in a protected system
|
|
# directory and away from pgAdmin to avoid invalidating signatures.
|
|
system_config_dir = '/etc/pgadmin'
|
|
if sys.platform.startswith('win32'):
|
|
system_config_dir = os.environ['CommonProgramFiles'] + '/pgadmin'
|
|
elif sys.platform.startswith('darwin'):
|
|
system_config_dir = '/Library/Preferences/pgadmin'
|
|
|
|
if os.path.exists(system_config_dir + '/config_system.py'):
|
|
try:
|
|
sys.path.insert(0, system_config_dir)
|
|
import config_system
|
|
config_system_settings = get_variables_from_module('config_system')
|
|
custom_config_settings.update(config_system_settings)
|
|
except ImportError:
|
|
pass
|
|
|
|
|
|
def evaluate_and_patch_config(config: dict) -> dict:
|
|
# Update settings for 'LOG_FILE', 'SQLITE_PATH', 'SESSION_DB_PATH',
|
|
# 'AZURE_CREDENTIAL_CACHE_DIR', 'KERBEROS_CCACHE_DIR', 'STORAGE_DIR'
|
|
# of DATA_DIR is user defined
|
|
data_dir_dependent_settings = \
|
|
['LOG_FILE', 'SQLITE_PATH', 'SESSION_DB_PATH',
|
|
'AZURE_CREDENTIAL_CACHE_DIR', 'KERBEROS_CCACHE_DIR', 'STORAGE_DIR']
|
|
|
|
if 'DATA_DIR' in custom_config_settings:
|
|
for setting in data_dir_dependent_settings:
|
|
if setting not in custom_config_settings:
|
|
data_dir = custom_config_settings['DATA_DIR']
|
|
file_dir_name = os.path.basename(config.get(setting))
|
|
config.update(
|
|
{setting: os.path.join(data_dir, file_dir_name)})
|
|
|
|
# To use psycopg3 driver, need to specify +psycopg in conn URI
|
|
if 'CONFIG_DATABASE_URI' in custom_config_settings:
|
|
custom_config_settings['CONFIG_DATABASE_URI'] = \
|
|
normalize_database_uri(
|
|
custom_config_settings['CONFIG_DATABASE_URI'])
|
|
|
|
# Finally update config user configs
|
|
config.update(custom_config_settings)
|
|
|
|
# Override DEFAULT_SERVER value from environment variable.
|
|
if 'PGADMIN_CONFIG_DEFAULT_SERVER' in os.environ:
|
|
config['DEFAULT_SERVER'] = os.environ['PGADMIN_CONFIG_DEFAULT_SERVER']
|
|
|
|
# Disable USER_INACTIVITY_TIMEOUT when SERVER_MODE=False
|
|
if not config.get('SERVER_MODE'):
|
|
config['USER_INACTIVITY_TIMEOUT'] = 0
|
|
# Enable PSQL in Desktop Mode.
|
|
config['ENABLE_PSQL'] = True
|
|
|
|
if config.get('SERVER_MODE'):
|
|
config.setdefault('USE_OS_SECRET_STORAGE', False)
|
|
config.setdefault('KEYRING_NAME', '')
|
|
else:
|
|
# NOTE: whether the selected keyring backend is actually *usable*
|
|
# (e.g. SecretService with no live D-Bus/GNOME-Keyring session) is
|
|
# probed separately, asynchronously, from create_app() via
|
|
# keyring_probe.start_async_probe() - see that module for why this
|
|
# can't be done safely here at config-import time.
|
|
config.setdefault('USE_OS_SECRET_STORAGE', True)
|
|
k_name = ''
|
|
try:
|
|
import keyring
|
|
k_name = keyring.get_keyring().name
|
|
except Exception:
|
|
k_name = 'fail Keyring'
|
|
|
|
if k_name == 'fail Keyring':
|
|
config['USE_OS_SECRET_STORAGE'] = False
|
|
config['KEYRING_NAME'] = ''
|
|
else:
|
|
config.setdefault('KEYRING_NAME', k_name)
|
|
|
|
config.setdefault('SESSION_COOKIE_PATH', config.get('COOKIE_DEFAULT_PATH'))
|
|
|
|
# if a script name is preset, session cookies should go to sub path
|
|
if 'SCRIPT_NAME' in os.environ and os.environ["SCRIPT_NAME"]:
|
|
config.update(dict({
|
|
'APPLICATION_ROOT': os.environ["SCRIPT_NAME"],
|
|
'SESSION_COOKIE_PATH': os.environ["SCRIPT_NAME"],
|
|
}))
|
|
|
|
return config
|