journeyman_configuration.py
Rama main · réplica de lectura del repositorio original.
"""Load Journeyman's YAML application configuration into process settings."""
import os
from pathlib import Path
import yaml
DEFAULT_CONFIGURATION_FILE = Path("/etc/journeyman/journeyman.yml")
SUPPORTED_CONFIGURATION_VERSION = 1
# YAML path -> legacy process setting. The YAML file is authoritative for all
# settings it defines. Environment variables remain an internal compatibility
# mechanism for existing Flask/application configuration code, but stale shell
# or legacy .env values must not override YAML.
_SETTING_MAP = {
("application", "config_class"): "JOURNEYMAN_CONFIG_CLASS",
("security", "secret_key"): "JOURNEYMAN_SECRET_KEY",
("security", "session_signing_key_file"): "JOURNEYMAN_SESSION_SIGNING_KEY_FILE",
("security", "session_signing_key_metadata_file"): "JOURNEYMAN_SESSION_SIGNING_KEY_METADATA_FILE",
("security", "credential_key_file"): "JOURNEYMAN_CREDENTIAL_KEY_FILE",
("security", "credential_keyring_dir"): "JOURNEYMAN_CREDENTIAL_KEYRING_DIR",
("security", "credential_active_key_file"): "JOURNEYMAN_CREDENTIAL_ACTIVE_KEY_FILE",
("authentication", "disabled"): "JOURNEYMAN_AUTHENTICATION_DISABLED",
("authentication", "fallback_admin_username"): "JOURNEYMAN_FALLBACK_ADMIN_USERNAME",
("authentication", "fallback_admin_password_hash_file"): "JOURNEYMAN_FALLBACK_ADMIN_PASSWORD_HASH_FILE",
("authentication", "fallback_admin_lifetime_minutes"): "JOURNEYMAN_FALLBACK_ADMIN_LIFETIME_MINUTES",
("authentication", "directory_admin_group_name"): "JOURNEYMAN_DIRECTORY_ADMIN_GROUP_NAME",
("authentication", "directory_user_group_name"): "JOURNEYMAN_DIRECTORY_USER_GROUP_NAME",
("authentication", "session_absolute_lifetime_seconds"): "JOURNEYMAN_AUTH_SESSION_ABSOLUTE_LIFETIME_SECONDS",
("authentication", "session_directory_revalidation_seconds"): "JOURNEYMAN_AUTH_SESSION_DIRECTORY_REVALIDATION_SECONDS",
("authentication", "login_rate_limit_attempts"): "JOURNEYMAN_LOGIN_RATE_LIMIT_ATTEMPTS",
("authentication", "login_rate_limit_window_seconds"): "JOURNEYMAN_LOGIN_RATE_LIMIT_WINDOW_SECONDS",
("database", "uri"): "JOURNEYMAN_DATABASE_URI",
("database", "pool_timeout_seconds"): "JOURNEYMAN_DATABASE_POOL_TIMEOUT_SECONDS",
("web", "public_fqdn"): "JOURNEYMAN_PUBLIC_FQDN",
("web", "tls_root"): "JOURNEYMAN_TLS_ROOT",
("web", "tls_certificate_path"): "JOURNEYMAN_TLS_CERTIFICATE_PATH",
("web", "tls_private_key_path"): "JOURNEYMAN_TLS_PRIVATE_KEY_PATH",
("web", "tls_chain_path"): "JOURNEYMAN_TLS_CHAIN_PATH",
("web", "https_port"): "JOURNEYMAN_HTTPS_PORT",
("paths", "repository_root"): "JOURNEYMAN_REPOSITORY_ROOT",
("paths", "runner_artifact_root"): "JOURNEYMAN_RUNNER_ARTIFACT_ROOT",
("paths", "log_root"): "JOURNEYMAN_LOG_ROOT",
("paths", "job_root"): "JOURNEYMAN_JOB_ROOT",
("paths", "inventory_snapshot_root"): "JOURNEYMAN_INVENTORY_SNAPSHOT_ROOT",
("paths", "signal_spool_root"): "JOURNEYMAN_SIGNAL_SPOOL_ROOT",
("environments", "application_path"): "JOURNEYMAN_APPLICATION_ENVIRONMENT_PATH",
("environments", "managed_root"): "JOURNEYMAN_MANAGED_ENVIRONMENT_ROOT",
("environments", "python_interpreters"): "JOURNEYMAN_ENVIRONMENT_PYTHONS",
("execution", "job_timeout_seconds"): "JOURNEYMAN_JOB_TIMEOUT_SECONDS",
("execution", "git_timeout_seconds"): "JOURNEYMAN_GIT_TIMEOUT_SECONDS",
("execution", "role_galaxy_executable"): "JOURNEYMAN_RUNNER_ROLE_GALAXY_EXECUTABLE",
("execution", "role_install_timeout_seconds"): "JOURNEYMAN_RUNNER_ROLE_INSTALL_TIMEOUT_SECONDS",
("retention", "job_days"): "JOURNEYMAN_JOB_RETENTION_DAYS",
("retention", "reaction_days"): "JOURNEYMAN_REACTION_RETENTION_DAYS",
("retention", "inventory_cache_seconds"): "JOURNEYMAN_INVENTORY_CACHE_RETENTION_SECONDS",
("retention", "purge_interval_seconds"): "JOURNEYMAN_DATA_RETENTION_PURGE_INTERVAL_SECONDS",
("outbound", "allowed_hosts"): "JOURNEYMAN_OUTBOUND_ALLOWED_HOSTS",
("rate_limits", "window_seconds"): "JOURNEYMAN_COSTLY_OPERATION_WINDOW_SECONDS",
("rate_limits", "preview_user_limit"): "JOURNEYMAN_COSTLY_PREVIEW_USER_LIMIT",
("rate_limits", "preview_global_limit"): "JOURNEYMAN_COSTLY_PREVIEW_GLOBAL_LIMIT",
("rate_limits", "dispatch_user_limit"): "JOURNEYMAN_COSTLY_LAUNCH_USER_LIMIT",
("rate_limits", "dispatch_global_limit"): "JOURNEYMAN_COSTLY_LAUNCH_GLOBAL_LIMIT",
("rate_limits", "inventory_user_limit"): "JOURNEYMAN_COSTLY_INVENTORY_USER_LIMIT",
("rate_limits", "inventory_global_limit"): "JOURNEYMAN_COSTLY_INVENTORY_GLOBAL_LIMIT",
("rate_limits", "repository_user_limit"): "JOURNEYMAN_COSTLY_REPOSITORY_USER_LIMIT",
("rate_limits", "repository_global_limit"): "JOURNEYMAN_COSTLY_REPOSITORY_GLOBAL_LIMIT",
("rate_limits", "environment_user_limit"): "JOURNEYMAN_COSTLY_ENVIRONMENT_USER_LIMIT",
("rate_limits", "environment_global_limit"): "JOURNEYMAN_COSTLY_ENVIRONMENT_GLOBAL_LIMIT",
}
_ALLOWED_TOP_LEVEL = {path[0] for path in _SETTING_MAP} | {"version"}
def configuration_path(path=None):
if path is not None:
return Path(path)
configured = str(os.environ.get("JOURNEYMAN_CONFIG", "") or "").strip()
# Before YAML configuration was introduced, JOURNEYMAN_CONFIG selected the
# Flask configuration class (normally app.config.ProductionConfig). Older
# installed systemd units may therefore still export that value. Translate
# it in-process so an upgrade does not mistake the Python class name for a
# filesystem path. New deployments always use JOURNEYMAN_CONFIG as the YAML
# filename and JOURNEYMAN_CONFIG_CLASS for the Flask configuration class.
if configured.startswith("app.config.") and "/" not in configured and "\\" not in configured:
os.environ.setdefault("JOURNEYMAN_CONFIG_CLASS", configured)
configured = str(DEFAULT_CONFIGURATION_FILE)
os.environ["JOURNEYMAN_CONFIG"] = configured
return Path(configured or DEFAULT_CONFIGURATION_FILE)
def _string_value(value, *, key):
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (str, int, float)):
return str(value)
if value is None:
return ""
if isinstance(value, list):
if not all(isinstance(item, (str, int, float)) for item in value):
raise ValueError(f"{key} must contain only scalar values")
return ",".join(str(item) for item in value)
raise ValueError(f"{key} must be a scalar value or list of scalar values")
def load_journeyman_configuration(path=None, *, required=False):
"""Load YAML configuration and make defined YAML values authoritative."""
explicit_path = path is not None
config_path = configuration_path(path)
if not config_path.is_file():
if required or (not explicit_path and os.environ.get("JOURNEYMAN_CONFIG")):
raise ValueError(f"Journeyman configuration file does not exist: {config_path}")
return False
try:
document = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
raise ValueError(f"Unable to read Journeyman configuration {config_path}: {exc}") from exc
if not isinstance(document, dict):
raise ValueError(f"{config_path}: YAML document root must be a mapping")
version = document.get("version")
if version != SUPPORTED_CONFIGURATION_VERSION:
raise ValueError(
f"{config_path}: configuration version must be {SUPPORTED_CONFIGURATION_VERSION}"
)
unknown_top = sorted(set(document) - _ALLOWED_TOP_LEVEL)
if unknown_top:
raise ValueError(f"{config_path}: unknown top-level key(s): {', '.join(unknown_top)}")
known_children = {}
for section, name in _SETTING_MAP:
known_children.setdefault(section, set()).add(name)
for section, allowed_names in known_children.items():
section_value = document.get(section, {})
if section_value is None:
continue
if not isinstance(section_value, dict):
raise ValueError(f"{config_path}: {section} must be a mapping")
unknown = sorted(set(section_value) - allowed_names)
if unknown:
raise ValueError(
f"{config_path}: unknown {section} key(s): {', '.join(unknown)}"
)
for yaml_path, environment_name in _SETTING_MAP.items():
section, name = yaml_path
section_value = document.get(section) or {}
if name not in section_value:
continue
os.environ[environment_name] = _string_value(
section_value[name],
key="{}.{}".format(section, name),
)
return True