From: Redouane Kachach Date: Mon, 26 Jan 2026 13:51:44 +0000 (+0100) Subject: mgr: add CephSecretsClient wrapper for ceph_secrets RPC X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=e737f8b73fed73980528d1cd71211e5d71d9778b;p=ceph.git mgr: add CephSecretsClient wrapper for ceph_secrets RPC Add a thin typed client around mgr.remote() for consuming the ceph_secrets module. Exposes get/set/rm, epoch and version queries, batch version fetch, scan and resolve helpers. Lives alongside ceph_secrets_types.py so any mgr module can import it without depending on the ceph_secrets package directly. Fixes: https://tracker.ceph.com/issues/74562 Assisted-by: Claude Assisted-by: ChatGPT Signed-off-by: Redouane Kachach --- diff --git a/src/pybind/mgr/ceph_secrets_client.py b/src/pybind/mgr/ceph_secrets_client.py new file mode 100644 index 000000000000..2386de710438 --- /dev/null +++ b/src/pybind/mgr/ceph_secrets_client.py @@ -0,0 +1,363 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional, Protocol, Union + +from ceph_secrets_types import SecretScope + +logger = logging.getLogger(__name__) + +ScopeArg = Union[SecretScope, str] + + +class MgrRemote(Protocol): + """Minimal interface required from the mgr object.""" + def remote(self, module: str, method: str, **kwargs: Any) -> Any: + ... + + +class CephSecretsClient: + """Thin client for calling the ceph_secrets mgr module via mgr.remote(). + + This file lives in src/pybind/mgr/ alongside ceph_secrets_types.py so + any mgr module can import it without depending on the ceph_secrets module + directory directly. + + All methods translate to a single mgr.remote() call and raise RuntimeError + if the ceph_secrets module is unreachable (e.g. not enabled). + + Typical usage:: + + client = CephSecretsClient(self) # self is a MgrModule instance + rec = client.secret_get("cephadm", SecretScope.HOST, "node1", "ssh_key") + if rec: + version = rec["metadata"]["version"] + """ + + DEFAULT_MODULE = "ceph_secrets" + + def __init__(self, mgr: MgrRemote, module: str = DEFAULT_MODULE) -> None: + self.mgr = mgr + self.module = module + + def _remote(self, method: str, **kwargs: Any) -> Any: + try: + return self.mgr.remote(self.module, method, **kwargs) + except Exception as e: + raise RuntimeError( + f"Cannot call secrets mgr-module '{self.module}' (is it enabled?) {e}" + ) from e + + # ---- epoch ---- + + def secret_get_epoch(self, namespace: str) -> int: + """Return the current mutation epoch for *namespace*. + + The epoch is a monotonically increasing integer that is incremented on + every successful set and on rm only when an existing secret is actually + removed (an idempotent rm returning not-found does not bump the epoch). It is + deliberately per-namespace: a mutation in namespace A does not change + the epoch of namespace B. + + Use this as a cheap change-detector: cache the epoch value after your + last sync; if it is unchanged on the next poll, no secrets in this + namespace have been mutated and you can skip a full refresh. + + Args: + namespace: The secret namespace to query (e.g. ``"cephadm"``). + + Returns: + The current epoch as a non-negative integer. Starts at 0 for a + namespace that has never been written to. + """ + return self._remote("secret_get_epoch", namespace=namespace) + + # ---- module API wrappers ---- + + def secret_get( + self, + namespace: str, + scope: ScopeArg, + target: str, + name: str, + reveal: bool = False, + ) -> Optional[Dict[str, Any]]: + """Retrieve a secret record by its full address. + + Returns the secret's metadata and, if *reveal* is True, its data + payload. Returns ``None`` if the secret does not exist; raises + RuntimeError if the module is unreachable. + + The returned dict contains a ``metadata`` object with fields such as ``version``, + ``created``, and ``updated``, etc. It intentionally does not include a + ``ref`` object because the caller already supplied the identity. + The ``data`` key is only present when *reveal* is True. + + Args: + namespace: The secret namespace (e.g. ``"cephadm"``). + scope: The secret scope — a :class:`SecretScope` value or its + string equivalent (``"global"``, ``"service"``, + ``"host"``, ``"custom"``). + target: The scope target. Must be non-empty for ``service`` and + ``host`` scopes; must be empty for ``global`` and + ``custom``. + name: The secret name or, for ``custom`` scope, the + slash-delimited path (e.g. ``"app/db/password"``). + reveal: If True, include the secret's data payload in the + response. Defaults to False to avoid accidental + exposure in logs. + + Returns: + A dict of the form ``{"metadata": {...}}`` plus optional ``data``, + or ``None`` if not found. + """ + return self._remote( + "secret_get", + namespace=namespace, + scope=scope, + target=target, + name=name, + reveal=reveal, + ) + + def secret_get_value( + self, + namespace: str, + scope: ScopeArg, + target: str, + name: str, + ) -> Optional[str]: + """Return the raw secret data string. + + Returns the stored opaque string directly, without any JSON envelope + or metadata. Returns ``None`` if the secret does not exist. + + Use this when you need the secret value itself — for example, to pass + a password to a subprocess or to resolve a credential at deploy time. + For metadata inspection or change-detection, use :meth:`secret_get` or + :meth:`secret_get_version` instead. + + Args: + namespace: The secret namespace. + scope: The secret scope. + target: The scope target (empty for ``global`` and ``custom``). + name: The secret name or custom path. + + Returns: + The stored string, or ``None`` if the secret does not exist. + """ + return self._remote( + "secret_get_value", + namespace=namespace, + scope=scope, + target=target, + name=name, + ) + + def secret_get_version( + self, + namespace: str, + scope: ScopeArg, + target: str, + name: str, + ) -> Optional[int]: + """Return the current version number of a secret. + + A convenience wrapper around :meth:`secret_get` for callers that only + need to check whether a secret has changed since they last read it, + without fetching its payload. + + The version is incremented on every successful :meth:`secret_set` call + for the same address. The first write produces version 1. + + Args: + namespace: The secret namespace. + scope: The secret scope. + target: The scope target (empty for ``global`` and ``custom``). + name: The secret name or custom path. + + Returns: + The current version as a positive integer, or ``None`` if the + secret does not exist. + """ + return self._remote( + "secret_get_version", + namespace=namespace, + scope=scope, + target=target, + name=name, + ) + + def secret_get_versions(self, uris: List[str]) -> Dict[str, Optional[int]]: + """Batch-fetch version numbers for a list of secret URIs. + + More efficient than calling :meth:`secret_get_version` in a loop when + you need to check many secrets at once (e.g. during a cephadm + reconciliation pass). + + Each entry in *uris* must be a canonical ``secret:/...`` URI, such as + one returned by ``SecretRef.to_uri()``. URIs that cannot be parsed are + skipped and logged at ERROR level on the module side; a missing key in + the result indicates malformed input rather than a not-found secret. + + Note that :meth:`scan_refs` may also return malformed or embedded + secret-like strings for validation/reporting. Pass only canonical + ``secret:/...`` URIs to this method. + + Args: + uris: A list of canonical secret URIs. Example:: + + [ + "secret:/cephadm/host/node1/ssh_key", + "secret:/cephadm/global/dashboard_password", + ] + + Returns: + A dict keyed by the input URI mapping to the current version + integer, or ``None`` if the secret does not exist. + """ + return self._remote("secret_get_versions", uris=uris) + + def secret_set( + self, + namespace: str, + scope: ScopeArg, + target: str, + name: str, + data: str, + user_made: bool = True, + editable: bool = True, + ) -> Dict[str, Any]: + """Create or update a secret. + + If a secret at the given address already exists its data is replaced + and its version is incremented. If it does not exist it is created at + version 1. The ``created`` timestamp is set on first write and never + changed thereafter; ``updated`` is refreshed on every write. + + Args: + namespace: The secret namespace. + scope: The secret scope. + target: The scope target (empty for ``global`` and ``custom``). + name: The secret name or custom path. + data: The secret payload as an opaque string. Callers are + responsible for any structure within it (e.g. + JSON-encoding a dict before storing and decoding after + retrieval). + user_made: Whether this secret was created by a human operator + rather than automatically by a Ceph component. Defaults + to True; set to False for programmatically generated + secrets. + editable: Whether the secret may be updated or removed by + automated tooling. Defaults to True. + + Returns: + A dict containing the written record's ``metadata`` object. The + response intentionally omits ``ref`` and never includes the data + payload. + """ + return self._remote( + "secret_set", + namespace=namespace, + scope=scope, + target=target, + name=name, + data=data, + user_made=user_made, + editable=editable, + ) + + def secret_rm( + self, + namespace: str, + scope: ScopeArg, + target: str, + name: str, + ) -> bool: + """Remove a secret. + + Idempotent: returns False if the secret did not exist rather than + raising. Raises RuntimeError only if the module is unreachable. + + Args: + namespace: The secret namespace. + scope: The secret scope. + target: The scope target (empty for ``global`` and ``custom``). + name: The secret name or custom path. + + Returns: + True if the secret existed and was removed; False if it was not + found. + """ + return bool( + self._remote( + "secret_rm", + namespace=namespace, + scope=scope, + target=target, + name=name, + ) + ) + + def scan_unresolved_refs(self, obj: Any, namespace: str) -> Any: + """Return all unresolved secret URI references found in *obj*. + + Walks *obj* recursively (dicts, lists, strings) and collects every + secret-like reference that cannot currently be resolved because it is + missing, malformed, embedded inside a larger string, or cannot be read + successfully. + + Useful for validation: call this before deploying a configuration + object to detect missing or invalid secret references early. + + Args: + obj: The object to scan. May be a dict, list, or any + JSON-like structure. + namespace: The namespace context used while scanning/reporting + references. + + Returns: + A collection of unresolved reference strings found in *obj*. + """ + return self._remote("scan_unresolved_refs", obj=obj, namespace=namespace) + + def scan_refs(self, obj: Any, namespace: str) -> Any: + """Return all secret URI references found in *obj*. + + Like :meth:`scan_unresolved_refs` but returns every whole-value secret + URI and every malformed or embedded secret-like reference found while + scanning. Malformed or embedded entries are returned as their raw string + value so callers can report them. + + Useful for auditing which secrets a configuration object depends on and + for surfacing malformed or embedded references before deployment. + + Args: + obj: The object to scan. + namespace: The namespace context for the scan. + + Returns: + A collection of all secret URI strings found in *obj*. + """ + return self._remote("scan_refs", obj=obj, namespace=namespace) + + def resolve_object(self, obj: Any) -> Any: + """Resolve all secret URI references in *obj*. + + Walks *obj* recursively and replaces every whole-value ``secret:/...`` + URI string with the stored opaque string for the referenced secret. + Surrounding whitespace around a URI reference is ignored, but embedding + a secret URI inside a larger string is rejected because partial + substitution is not supported. + + Args: + obj: The object to resolve. May be a dict, list, or any JSON-like + structure containing ``secret:/...`` URI strings. + + Returns: + A resolved copy of *obj* with all secret URIs replaced by their stored opaque strings. + Raises RuntimeError if any referenced secret cannot be resolved + or if the module is unreachable. + """ + return self._remote("resolve_object", obj=obj)