Ctrl K
ComunidadGuías…

journeyman_configuration.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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