Source code for saltext.github.utils.gh.profile

"""
Resolution of Salt profile configuration into GitHub connections,
shared between the ``github``, ``github_org`` and ``github_repo``
execution modules as well as the ``github`` resource type.

Profiles are resolved directly from ``opts``, mirroring ``config.get``'s
lookup order: minion configuration, then grains, then pillar, then
master configuration (which reaches the minion via the master's
``pillar_opts`` setting, rendered under ``pillar["master"]``). SDB URIs
in profile values are rendered on access, like ``config.get`` renders
them. ``config.get`` itself cannot serve as the backend since it is
unavailable to parts of the resource subsystem (type discovery runs
before the minion's module loader exists).
"""

import logging
from pathlib import Path

from salt.exceptions import CommandExecutionError

from saltext.github.utils.gh.cache import MISS
from saltext.github.utils.gh.cache import PersistentCache
from saltext.github.utils.gh.client import API_URL_DEFAULT
from saltext.github.utils.gh.client import GitHubConnection
from saltext.github.utils.gh.client import build_app_auth
from saltext.github.utils.gh.client import get_app_connection_ident
from saltext.github.utils.gh.client import get_connection_ident
from saltext.github.utils.gh.client import resolve_app_installation_id

log = logging.getLogger(__name__)

_UNSET = object()


def _render_value(value, opts, utils=None):
    """
    Render ``sdb://`` URIs in scalar configuration values, mirroring
    what ``config.get`` does for profile values.
    """
    if isinstance(value, str) and value.startswith("sdb://"):
        # Only required to resolve SDB URIs
        import salt.utils.sdb  # pylint: disable=import-outside-toplevel

        return salt.utils.sdb.sdb_get(value, opts, utils=utils)
    return value


[docs] def get_config_value(profile, config_name, *, opts, context=None, utils=None, default=_UNSET): """ Return a profile's configuration value based on the supplied configuration name. See the module docstring for the lookup order and SDB handling; the first scope carrying the requested value wins. profile Profile name that contains configuration information. config_name Configuration item name to look up. opts The minion configuration (``__opts__``). context Pass ``__context__`` to cache rendered values, saving repeated SDB lookups. utils Pass ``__utils__`` when available to avoid building a fresh utils loader for SDB rendering. default Value to return when the profile does not carry the item. Without it, a missing item raises a ``CommandExecutionError``. """ cache = None if context is not None: cache = context.setdefault("gh_profile", {}).setdefault(profile, {}) if cache is None or config_name not in cache: pillar = opts.get("pillar") or {} master_pillar = pillar.get("master") scopes = ( opts, opts.get("grains") or {}, pillar, master_pillar if isinstance(master_pillar, dict) else {}, ) configs = [ scope[profile] for scope in scopes if isinstance(scope.get(profile), dict) and scope[profile] ] if not configs: raise CommandExecutionError( f"Authentication information could not be found for the '{profile}' profile." ) value = None for config in configs: found = config.get(config_name) if found is not None: value = _render_value(found, opts, utils=utils) break if cache is not None: cache[config_name] = value else: value = cache[config_name] if value is None: if default is not _UNSET: return default raise CommandExecutionError( f"The '{config_name}' parameter was not found in the '{profile}' profile." ) return value
[docs] def get_api_url(profile, *, opts, context=None, utils=None): """ Return the configured GitHub API base URL, defaulting to the public one. profile Profile name that contains configuration information. opts The minion configuration (``__opts__``). context Pass ``__context__`` to cache rendered values. utils Pass ``__utils__`` when available (SDB rendering). """ return get_config_value( profile, "api_url", opts=opts, context=context, utils=utils, default=API_URL_DEFAULT ).rstrip("/")
[docs] def get_owner(profile, *, opts, context=None, utils=None): """ Return the configured default repository owner: the profile's ``owner``, falling back to the legacy ``org_name`` alias. Returns ``None`` when neither is set, making the connection default to the token's user. profile Profile name that contains configuration information. opts The minion configuration (``__opts__``). context Pass ``__context__`` to cache rendered values. utils Pass ``__utils__`` when available (SDB rendering). """ for key in ("owner", "org_name"): owner = get_config_value( profile, key, opts=opts, context=context, utils=utils, default=None ) if owner is not None: return owner return None
[docs] def get_conn(profile, *, opts, context, utils=None): """ Return a :class:`~saltext.github.utils.gh.client.GitHubConnection` for a profile, cached in ``context`` for performance. Unless the profile disables it (:ghconf:`cache`), the connection carries a :class:`~saltext.github.utils.gh.cache.PersistentCache` for slow-changing API data, surviving across processes. profile Profile name that contains configuration information. opts The minion configuration (``__opts__``). context Pass ``__context__``. Caches the connection and rendered profile values. utils Pass ``__utils__`` when available (SDB rendering). """ profile_key = f"github.profile.{profile}" if profile_key in context: return context[profile_key] token = get_config_value( profile, "token", opts=opts, context=context, utils=utils, default=None ) app_id = get_config_value( profile, "app_id", opts=opts, context=context, utils=utils, default=None ) api_url = get_api_url(profile, opts=opts, context=context, utils=utils) owner = get_owner(profile, opts=opts, context=context, utils=utils) permissions = get_config_value( profile, "permissions", opts=opts, context=context, utils=utils, default=None ) if token is not None and app_id is not None: raise CommandExecutionError( f"The '{profile}' profile specifies both 'token' and 'app_id', " "which are mutually exclusive." ) if token is not None: if permissions is not None: raise CommandExecutionError( f"The 'permissions' parameter is only valid for GitHub App ('app_id') " f"profiles, but the '{profile}' profile uses token authentication." ) # Hash the token to keep the secret out of context dumps ident = get_connection_ident(token, api_url) elif app_id is not None: if not owner: raise CommandExecutionError( f"GitHub App authentication requires the 'owner' parameter " f"in the '{profile}' profile." ) if permissions is not None and not isinstance(permissions, dict): raise CommandExecutionError( f"The 'permissions' parameter of the '{profile}' profile must be a " "mapping of permission name to access level." ) # Derived from the app's identity, not its short-lived # installation tokens - persistent cache banks survive rotation. # Differently restricted profiles must not share connections. ident = get_app_connection_ident(app_id, owner, api_url, permissions) else: raise CommandExecutionError( f"The '{profile}' profile is missing authentication credentials. " "Specify either 'token' or 'app_id' + 'private_key'/'private_key_path'." ) conn_key = f"github.conn.{ident}" # This config is HTTP connection-independent. We should reuse a client, if it exists. cache_enabled = get_config_value( profile, "cache", opts=opts, context=context, utils=utils, default=True ) if conn_key in context: other_conn = context[conn_key] store = ... if bool(other_conn.store) is not cache_enabled: store = PersistentCache(opts, ident) if cache_enabled else None context[profile_key] = context[conn_key].with_(owner=owner, store=store) else: store = PersistentCache(opts, ident) if cache_enabled else None auth = token if auth is None: auth = _get_app_auth( profile, app_id, owner=owner, api_url=api_url, permissions=permissions, store=store, opts=opts, context=context, utils=utils, ) context[profile_key] = context[conn_key] = GitHubConnection( auth, owner, api_url=api_url, ident=ident, store=store, ) return context[profile_key]
def _get_app_auth( profile, app_id, *, owner, api_url, permissions, store, opts, context, utils=None ): """ Build the PyGithub auth object for a GitHub App profile. When the profile does not pin :ghconf:`installation_id`, the app's installation on ``owner`` is discovered via the API (cached in ``store``, when available). """ private_key = get_config_value( profile, "private_key", opts=opts, context=context, utils=utils, default=None ) private_key_path = get_config_value( profile, "private_key_path", opts=opts, context=context, utils=utils, default=None ) if (private_key is None) is (private_key_path is None): raise CommandExecutionError( f"GitHub App authentication requires exactly one of 'private_key' or " f"'private_key_path', the '{profile}' profile specifies " f"{'both' if private_key is not None else 'neither'}." ) if private_key_path is not None: try: private_key = Path(private_key_path).read_text(encoding="utf-8") except OSError as err: raise CommandExecutionError( f"Failed reading 'private_key_path' of the '{profile}' profile: {err}" ) from err installation_id = get_config_value( profile, "installation_id", opts=opts, context=context, utils=utils, default=None ) if installation_id is None and store is not None: installation_id = store.get_meta("installation_id") if installation_id is MISS: installation_id = None if installation_id is None: installation_id = resolve_app_installation_id(app_id, private_key, owner, api_url) if store is not None: store.set_meta("installation_id", installation_id) return build_app_auth(app_id, private_key, installation_id, permissions)
[docs] def resource_decl(opts): """ Return the ``github`` resource type's declaration from the resource pillar subtree (``{}`` when unset or not a mapping). opts The minion configuration (``__opts__``). """ # Local import: the resource subsystem only exists in Salt >= 3008, # while this module is also imported on earlier releases. import salt.utils.resources # pylint: disable=import-outside-toplevel decl = salt.utils.resources.pillar_resources_tree(opts).get("github") return decl if isinstance(decl, dict) else {}
[docs] def resource_profile(opts): """ Return the default profile name configured in the ``github`` resource declaration, defaulting to ``github``. opts The minion configuration (``__opts__``). """ return resource_decl(opts).get("profile") or "github"
[docs] def resource_owners(opts): """ Return the owners named in the ``github`` resource declaration as a list (an empty one when unset). The declaration's ``owners`` may be a plain list of owner names or a mapping of owner name to per-owner settings (``profile``, ``repos``, ``exclude``, ``include_archived``). opts The minion configuration (``__opts__``). """ owners = resource_decl(opts).get("owners") if isinstance(owners, dict): return list(owners) return list(owners or [])
def _owner_settings(opts, owner): """ Return one owner's settings mapping from the declaration's ``owners``, matched case-insensitively (``{}`` when the owners are a plain list or the owner carries no settings). """ owners = resource_decl(opts).get("owners") if isinstance(owners, dict) and owner is not None: for name, settings in owners.items(): if name.lower() == owner.lower() and isinstance(settings, dict): return settings return {}
[docs] def resource_owner_setting(opts, owner, key, default=None): """ Return a declaration setting for one owner: the owner's own value from the ``owners`` mapping when set, otherwise the declaration-level value, otherwise ``default``. opts The minion configuration (``__opts__``). owner Owner (organization or user) login. key Setting name (``profile``, ``repos``, ``exclude``, ``include_archived``). default Value to return when neither the owner nor the declaration carries the setting. """ value = _owner_settings(opts, owner).get(key) if value is None: value = resource_decl(opts).get(key) return value if value is not None else default
[docs] def resource_owner_profile(opts, owner): """ Return the profile name to use for one owner's repositories: the owner's ``profile`` override from the declaration's ``owners`` mapping when present, otherwise the declaration's default profile. Owner names are matched case-insensitively. opts The minion configuration (``__opts__``). owner Owner (organization or user) login. """ return resource_owner_setting(opts, owner, "profile", "github")
[docs] def resource_profiles(opts): """ Return all distinct profile names referenced by the ``github`` resource declaration: the default one plus any per-owner overrides. opts The minion configuration (``__opts__``). """ profiles = [resource_profile(opts)] owners = resource_decl(opts).get("owners") if isinstance(owners, dict): for settings in owners.values(): if isinstance(settings, dict) and settings.get("profile"): if settings["profile"] not in profiles: profiles.append(settings["profile"]) return profiles
[docs] def resource_conn(opts, context, *, profile=None, owner=None, utils=None): """ Return the :class:`~saltext.github.utils.gh.client.GitHubConnection` for a ``github`` resource context, resolved from ``opts``. opts The minion configuration (``__opts__``). context Cache mapping for the connection (``__context__``). profile Profile name override. When unset, the profile is resolved from the resource declaration: the targeted ``owner``'s override when declared, otherwise the declaration's default profile (``github`` when unset). owner Owner the connection is for, used to resolve per-owner profile overrides. Ignored when ``profile`` is passed. utils Pass ``__utils__`` when available (SDB rendering). """ return get_conn( profile or resource_owner_profile(opts, owner), opts=opts, context=context, utils=utils )