Skip to content

Add revoke Vault token functionality to OIDC lookup creds#176

Merged
webknjaz merged 39 commits intoansible:develfrom
fincamd:AAP-64519-vault-token-revocation
Apr 1, 2026
Merged

Add revoke Vault token functionality to OIDC lookup creds#176
webknjaz merged 39 commits intoansible:develfrom
fincamd:AAP-64519-vault-token-revocation

Conversation

@fincamd
Copy link
Copy Markdown
Contributor

@fincamd fincamd commented Mar 13, 2026

AAP-64519

Summary by CodeRabbit

  • New Features

    • Automatic, best-effort token revocation after KV and SSH credential operations; respects Vault namespace and TLS, suppresses failures and logs a warning.
  • Bug Fixes

    • Improved KV v2 mount/path resolution fallback when backend not specified.
    • Safer token cleanup integrated into credential flows.
  • Tests

    • Added unit and integration tests covering revoke behavior, namespace handling, KV/SSH flows and workload-identity scenarios.
  • pre-commit notes
    • As part of the implementation, we made vault_token a private function. This triggers a warning by pylint saying we're accessing protected members of the hashivault plugin from the test file. This is expected and will provide a solution before merging

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a module-level logger, a best-effort revoke_token(token, **kwargs) that POSTs to Vault auth/token/revoke-self and suppresses errors, integrates revocation into kv_backend and ssh_backend finally blocks for workload-identity/OIDC flows, adjusts KV v2 mount/path resolution, and adds unit tests for revocation and integration.

Changes

Cohort / File(s) Summary
Token revocation & logger
src/awx_plugins/credentials/hashivault.py
Added logger = getLogger(__name__) and revoke_token(token: str, **kwargs) that performs a best-effort POST to auth/token/revoke-self, sets X-Vault-Token and optional X-Vault-Namespace, uses cacert for TLS verification, suppresses exceptions and logs a warning on failure.
Backend integration & KV v2 path handling
src/awx_plugins/credentials/hashivault.py
Refactored kv_backend(**kwargs) and ssh_backend(**kwargs) to scope URL/cert/session inside try, wrap main logic in try/finally, and call revoke_token(token, **kwargs) in finally when workload_identity_token exists. KV v2 mount/path derivation now uses a guarded try/except fallback when secret_backend is not provided.
Tests: revoke & backend integration
tests/unit/credentials/hashivault_test.py
Added tests for revoke_token() (no-op on empty token, sets headers, suppresses exceptions and logs warning) and parameterized integration-style tests asserting kv_backend/ssh_backend obtain tokens via handle_auth() and trigger revoke_token for OIDC/workload-identity scenarios. Added contextlib usage to suppress backend exceptions in tests.

Sequence Diagram(s)

sequenceDiagram
  participant Backend as "kv_backend / ssh_backend"
  participant Auth as "hashivault.handle_auth"
  participant Vault as "Vault API"
  participant Revoke as "hashivault.revoke_token"

  Backend->>Auth: request token (OIDC / workload identity)
  Auth->>Vault: auth request (OIDC/workload)
  Vault-->>Auth: token + metadata
  Auth-->>Backend: return token & client/session
  Backend->>Vault: perform backend operation (KV read / SSH sign)
  Vault-->>Backend: backend response
  Backend->>Revoke: finally -> revoke_token(token, namespace, cacert)
  Revoke->>Vault: POST auth/token/revoke-self (X-Vault-Token, X-Vault-Namespace)
  Vault-->>Revoke: revoke response (ignored)
  Revoke-->>Backend: returns (exceptions suppressed)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title clearly and concisely summarizes the main change: adding token revocation functionality to OIDC lookups in Vault credentials.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
tests/unit/credentials/hashivault_test.py (1)

447-506: Add a static-token regression case.

These new backend tests only assert revocation for workload_identity_token, but kv_backend() and ssh_backend() are also used by the non-OIDC plugins. Please add a case showing that revoke_token() is not called when authentication comes from a caller-supplied token, so this regression stays covered once the production fix lands.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/credentials/hashivault_test.py` around lines 447 - 506, Add tests
mirroring test_kv_backend_revokes_jwt_token and
test_ssh_backend_revokes_jwt_token but using a caller-supplied 'token' key
instead of 'workload_identity_token'; call hashivault.kv_backend(...) and
hashivault.ssh_backend(...) with kwargs containing 'token', mock
hashivault.handle_auth and requests.Session responses and CertFiles as in the
existing tests, and assert mock_revoke.assert_not_called() (i.e., revoke_token
is not invoked) to ensure no revocation occurs for static tokens; reference
kv_backend, ssh_backend, revoke_token, and handle_auth when locating where to
add these new test cases.
src/awx_plugins/credentials/hashivault.py (1)

523-524: Don't suppress revocation failures without any signal.

A blanket except Exception: pass makes this security control a silent no-op whenever the revoke call is misconfigured or Vault starts rejecting it, and it also hides non-network bugs in the cleanup path. Keep the call best-effort, but catch request/IO failures explicitly and emit at least a debug or warning log.

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/awx_plugins/credentials/hashivault.py`:
- Around line 645-647: The finally blocks call revoke_token(token, **kwargs)
unconditionally, which revokes caller-supplied static Vault tokens; change the
flow so only tokens minted by our plugin are revoked: have handle_auth() (or the
code path that creates a new Vault token) set a marker like
kwargs['_generated_by_plugin'] = True when it issues a temporary token, and in
the finally blocks (where revoke_token is invoked) only call revoke_token if
kwargs.pop('_generated_by_plugin', False) is True (or otherwise check a local
caller_supplied_token boolean captured before auth); update both revoke
locations (the revoke_token calls around lines referenced) to use that guard so
caller-managed tokens are never revoked.

---

Nitpick comments:
In `@tests/unit/credentials/hashivault_test.py`:
- Around line 447-506: Add tests mirroring test_kv_backend_revokes_jwt_token and
test_ssh_backend_revokes_jwt_token but using a caller-supplied 'token' key
instead of 'workload_identity_token'; call hashivault.kv_backend(...) and
hashivault.ssh_backend(...) with kwargs containing 'token', mock
hashivault.handle_auth and requests.Session responses and CertFiles as in the
existing tests, and assert mock_revoke.assert_not_called() (i.e., revoke_token
is not invoked) to ensure no revocation occurs for static tokens; reference
kv_backend, ssh_backend, revoke_token, and handle_auth when locating where to
add these new test cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7e3dd99c-e09e-491f-a557-9cb589d8e0ee

📥 Commits

Reviewing files that changed from the base of the PR and between 6f0a4de and cdf946f.

📒 Files selected for processing (2)
  • src/awx_plugins/credentials/hashivault.py
  • tests/unit/credentials/hashivault_test.py

@fincamd fincamd changed the title Add revoke Vault token functionality to OIDC lookup creds [WIP] - Add revoke Vault token functionality to OIDC lookup creds Mar 13, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/awx_plugins/credentials/hashivault.py (1)

649-651: ⚠️ Potential issue | 🔴 Critical

Don't revoke caller-managed Vault tokens.

These backends are shared by the OIDC and static-token plugins, and handle_auth() returns kwargs['token'] unchanged on Lines 434-435. As written, a credential configured with a long-lived Vault token will revoke that token after the first lookup/sign; only tokens minted for this request should be cleaned up.

Suggested change
 def kv_backend(**kwargs):
+    caller_supplied_token = bool(kwargs.get('token'))
     token = handle_auth(**kwargs)
@@
     finally:
         # Revoke token to minimize token lifetime and improve security posture
-        revoke_token(token, **kwargs)
+        if not caller_supplied_token:
+            revoke_token(token, **kwargs)

 def ssh_backend(**kwargs):
+    caller_supplied_token = bool(kwargs.get('token'))
     token = handle_auth(**kwargs)
@@
     finally:
         # Revoke token to minimize token lifetime and improve security posture
-        revoke_token(token, **kwargs)
+        if not caller_supplied_token:
+            revoke_token(token, **kwargs)

Also applies to: 698-700

🧹 Nitpick comments (1)
src/awx_plugins/credentials/hashivault.py (1)

523-524: Make revoke failures observable.

If TLS or request construction breaks here, token cleanup silently stops working with no signal. A debug log keeps the best-effort behavior while making regressions diagnosable.

Suggested change
+import logging
+
 import requests
@@
+logger = logging.getLogger(__name__)
+
@@
-    except Exception:
-        pass
+    except Exception:
+        logger.debug('Vault token revocation failed', exc_info=True)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/awx_plugins/credentials/hashivault.py` around lines 523 - 524, Replace
the silent swallow at the revoke failure site (the "except Exception: pass" in
src/awx_plugins/credentials/hashivault.py) with a best-effort debug log: catch
the exception as a variable (e.g. "except Exception as e"), call the module's
existing logger (use the logger instance used elsewhere in this file, e.g.
"logger" or "self.logger") to emit a debug-level message including the exception
details (or use exc_info=True) and then continue to preserve the best-effort
revoke behavior; this makes TLS/request construction or other revoke errors
observable while keeping the current fallback flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/awx_plugins/credentials/hashivault.py`:
- Around line 523-524: Replace the silent swallow at the revoke failure site
(the "except Exception: pass" in src/awx_plugins/credentials/hashivault.py) with
a best-effort debug log: catch the exception as a variable (e.g. "except
Exception as e"), call the module's existing logger (use the logger instance
used elsewhere in this file, e.g. "logger" or "self.logger") to emit a
debug-level message including the exception details (or use exc_info=True) and
then continue to preserve the best-effort revoke behavior; this makes
TLS/request construction or other revoke errors observable while keeping the
current fallback flow.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 54062183-19e0-4fb4-a6ce-2cfd50798c84

📥 Commits

Reviewing files that changed from the base of the PR and between cdf946f and f5de25a.

📒 Files selected for processing (2)
  • src/awx_plugins/credentials/hashivault.py
  • tests/unit/credentials/hashivault_test.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/unit/credentials/hashivault_test.py

@fincamd fincamd force-pushed the AAP-64519-vault-token-revocation branch from bc0e305 to 63a540b Compare March 16, 2026 08:18
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 16, 2026

Codecov Report

❌ Patch coverage is 96.92308% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.35%. Comparing base (6cdd492) to head (09eb6e5).
⚠️ Report is 40 commits behind head on devel.
✅ All tests successful. No failed tests found.

❌ Your patch status has failed because the patch coverage (96.92%) is below the target coverage (100.00%). You can increase the patch coverage or adjust the target coverage.

@fincamd fincamd force-pushed the AAP-64519-vault-token-revocation branch from 6405fbb to 20f46a1 Compare March 16, 2026 08:41
@melissalkelly
Copy link
Copy Markdown
Member

melissalkelly commented Mar 17, 2026

Notes from review call:

  1. “function with too many statements” suggestion is to add a lint ignore comment
  2. Flake8 failures should be addressed
  3. Increase unit test coverage to meet 100% target, otherwise check in with awx-plugins maintainers for their feedback

return {'role': kwargs.get('jwt_role'), 'jwt': workload_identity_token}


def revoke_token(token: str, **kwargs):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't accept arbitrary keword args. Make them all known. Especially, since this is a private API that's only used internally. The existing catch-all pattern, however terrible, only exists in the callables that are public API, AFAICS. I see no reason to additionally obscure the logic here.

Additionally, add a single underscore to the name to show that it's indeed private.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's a better signature that makes it obvious what's to be passed, makes sure it's always called w/ the kwargs syntax and helps understand typing expectations:

Suggested change
def revoke_token(token: str, **kwargs):
def _revoke_token(*, token: str, url: str, cacert: str | None, namespace: str) -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been addressed in this commit 410a0a7

Comment on lines +563 to +564
with contextlib.suppress(Exception):
backend(**backend_kwargs)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs justification so that the reader would know why you're doing this here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I figured how to remove this and fix the tests in 5126a1f

Comment on lines +551 to +559
mock_handle_auth = mocker.patch.object(
hashivault,
'handle_auth',
return_value='test_token',
)
mock_revoke = mocker.patch.object(hashivault, 'revoke_token')
mocker.patch('requests.Session')
mocker.patch.object(hashivault, 'CertFiles')
mocker.patch.object(hashivault, 'raise_for_status')
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remember to always use autospec in your mocks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in ab17d6c

mocker.patch.object(hashivault, 'CertFiles')
mocker.patch.object(hashivault, 'raise_for_status')

backend = getattr(hashivault, backend_func)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using dynamic trampoline logic for looking up things that are always there and don't need fallbacks, pass these objects as params by accessing the attributes directly, like test_non_oidc_plugins_have_no_internal_fields does, for example.

Copy link
Copy Markdown
Contributor Author

@fincamd fincamd Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@webknjaz How would you type the function parameter? I've tried these options and neither of them satisfy the linter:

-     backend_func: object,
-     backend_func: _t.Callable[..., str],
-     backend_func: _t.Callable[..., _t.Any],
-     backend_func: _t.Callable[dict[str, str], str],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The last one should be fine. But it won't work unless you actually type the definition itself.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done here f60a6b5

}
if secret_backend:
path_segments = [secret_backend, 'data', secret_path]
def kv_backend(**kwargs): # noqa: PLR0915
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unjustified linting suppressions must not be added. They must always be paired with explanatory comments, expecially those without human-readable identifiers with a bunch of numbers that don't really mean anything to the reader.


Anyway, this one doesn't look justified regardless. It seems like you want to have a cleanup in a bunch of high-level functions. This can likely be solved elegantly via @contextlib.contextmanager, which produces not only as a CM but the result can also be used as a function decorator too, saving us a lot of unnecessary indentation.

While writing this I'd drafted smth like this:

import contextlib as _ctx
import functools as _ft

# the above goes to the top of the module ^
...


@_ctx.contextmanager
def _clean_up_oidc_token(*args, **kwargs):
    try:
        yield
    finally:
        if 'workload_identity_token' not in kwargs:
            return

        _revoke_token(
            token=...,
            url=kwargs['url'],
            cacert=kwargs.get('cacert'),
            namespace=kwargs.get('namespace'),
        )


def _auto_revoke_oidc_token(call_the_decorated_function, /):
    @_ft.wraps(call_the_decorated_function)
    def decorate_the_function_with_cleanup(*args, **kwargs):
        with _clean_up_oidc_token(*args, **kwargs):
            call_the_decorated_function(*args, **kwargs)

    return decorate_the_function_with_cleanup


@_auto_revoke_oidc_token  # <-- apply this to other callables the same way
def kv_backend(**kwargs):
    ...  # the original contents here

But then I realized that the token's only available within the plugin functions. So next I realized that the cleanup is actually quite coupled with the auth handling logic so handle_auth() should go into a CM:

import contextlib as _ctx
import functools as _ft

# the above goes to the top of the module ^
...


@_ctx.contextmanager
def _lifetime_bound_token(**kwargs) -> str:
    token_is_revocable = 'workload_identity_token' in kwargs  # e.g. ephemeral vault token

    auth_token = handle_auth(**kwargs)

    try:
        yield auth_token
    finally:
        if not token_is_revocable:
            return

        _revoke_token(
            token=...,
            url=kwargs['url'],
            cacert=kwargs.get('cacert'),
            namespace=kwargs.get('namespace'),
        )


def _inject_auth_token(call_the_decorated_function, /):
    @_ft.wraps(call_the_decorated_function)
    def decorate_the_function_with_cleanup(**kwargs):
        with _lifetime_bound_token(**kwargs) as http_auth_token:
            call_the_decorated_function(token=http_auth_token, **kwargs)

    return decorate_the_function_with_cleanup


@_inject_auth_token
def kv_backend(*, token: str, **kwargs):
    ...  # the original contents here, with the `handle_auth()` call removed


@_inject_auth_token
def ssh_backend(*, token: str, **kwargs):
    ...  # the original contents here, with the `handle_auth()` call removed

Important

The above is sketched in-browser so it may contain typos/cosmetic linting errors
but should be good to go otherwise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much a cleaner solution, thanks for taking the time to get this review in @webknjaz . I appreciate it a lot!!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I was thinking of a similar contextmanager function:

import contextlib as _ctx

@_ctx.contextmanager
def vault_token(**kwargs):
    """Context manager that yields a Vault token and revokes it on exit if obtained via workload identity."""
    token = handle_auth(**kwargs)
    try:
        yield token
    finally:
        # Only revoke tokens obtained via workload identity authentication
        if kwargs.get('workload_identity_token'):
            url = urljoin(kwargs['url'], 'v1/auth/token/revoke-self')
            sess = requests.Session()
            sess.headers['X-Vault-Token'] = token
            if kwargs.get('namespace'):
                sess.headers['X-Vault-Namespace'] = kwargs['namespace']
            with CertFiles(kwargs.get('cacert')) as cert:
                resp = sess.post(url, verify=cert, timeout=30)
            resp.raise_for_status() # see my other comment

Then

token = handle_auth(**kwargs)
...

becomes

with vault_token(**kwargs) as token:
    ...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dleehr my suggestion has abstraction layers separated and doesn't put things from multiple layers together. It's verbose to make said layers obvious. And the external decorator is a better fit for the handlers because (1) it doesn't add indentation which is better for maintainability, not just for the diff in review and (2) the auth is a common separate process so it doesn't make sense semantically to make it a part of the handlers but something that is performed externally. This can also be scaled to other plugins by moving to a helper module later if needed. Note that explicit args is better then the unclear semantics of dict.get('thing_name', None) which is one of "arg is missing, or arg is None, or arg is an empty string, or arg is anything falsy" that's not really explained in the condensed style that is overly indented internally as well.

I mentioned this here https://github.com/ansible/awx-plugins/pull/176/changes#r2988429724 and here https://github.com/ansible/awx-plugins/pull/176/changes#r2988429724.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Context manager implementation, unindenting of the functions and explicit parameter listing in 410a0a7

@webknjaz webknjaz added the enhancement New feature or request label Mar 18, 2026
fincamd added 3 commits March 31, 2026 13:30
Modify the tests so they use the new context manager approach for testing
Removed logging module and function
Removed try-except in most scenarios (the vault token CM still uses it)
@webknjaz webknjaz force-pushed the AAP-64519-vault-token-revocation branch from 811a713 to 05044b8 Compare March 31, 2026 11:30
fincamd and others added 23 commits March 31, 2026 16:43
- Hidden imports to not clutter global namespace
- Added context manager as decorator for kv and ssh backends
- Removed the usage of kwargs into explicit function argument names
  for kv and ssh backends

Co-Authored-By: Sviatoslav Sydorenko <webknjaz@redhat.com>
This is the actual use internally and so we shouldn't allow a mix of
invocation styles for consistency.
Previously, it was passed through mutating an unpacked dict but
it's more readable to pass it into the call directly.
The stdlib docs suggest that this is the way but this somehow does not
influence how MyPy processes typing.
PEP 612 defines `typing.ParamSpec` but leaves out the problem of using
`typing.Concatenate` for declaring keyword-only arguments that would
not be considered a part of a decorator's generic keyword arguments
as out of the scope [[1]].

This means that we cannot annotate decorators that add or remove
arguments when wrapping decorated functions accurately.

We, however, can attempt using type narrowing to let MyPy know what's
definitely not expected to be passed into the wrapper function, which
this patch does through the `assert` statements.

[1]: https://peps.python.org/pep-0612/#concatenating-keyword-parameters
This patch reduces the number of duplicate checks in the test function
as means of refactoring and reducing the control flow branches.
This allows preserving signatures of the decorated function
annotations without leaking the token managed on a dedicated abstration
layer and removing it from the context upon the lifetime exit.
This is needed due to mandatory imports exceeding the limit. The
previously existing module structure pattern encapsulates big chunks
of implementation in one module. In the future, we should look into
breaking it up into smaller parts so that the suppression would not
be needed anymore.
@webknjaz webknjaz force-pushed the AAP-64519-vault-token-revocation branch from 05044b8 to f5203c6 Compare March 31, 2026 15:03
@webknjaz webknjaz merged commit 3e047cb into ansible:devel Apr 1, 2026
42 of 44 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants