From 387cc59ea07a3d00ad3b1a2c344005261cbaeb37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Sj=C3=B6gren?= Date: Fri, 13 Mar 2026 15:08:27 +0000 Subject: [PATCH] github_secrets_info: new module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Sjögren --- .github/BOTMETA.yml | 2 + plugins/modules/github_secrets_info.py | 174 ++++++++++++++++++ .../modules/test_github_secrets_info.py | 101 ++++++++++ 3 files changed, 277 insertions(+) create mode 100644 plugins/modules/github_secrets_info.py create mode 100644 tests/unit/plugins/modules/test_github_secrets_info.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index b3873a1c42..bcaa136fdb 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -642,6 +642,8 @@ files: maintainers: adrianmoisey $modules/github_repo.py: maintainers: atorrescogollo + $modules/github_secrets_info.py: + maintainers: konstruktoid $modules/gitlab_: keywords: gitlab source_control maintainers: $team_gitlab diff --git a/plugins/modules/github_secrets_info.py b/plugins/modules/github_secrets_info.py new file mode 100644 index 0000000000..435dde4efa --- /dev/null +++ b/plugins/modules/github_secrets_info.py @@ -0,0 +1,174 @@ +#!/usr/bin/python + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: github_secrets_info +short_description: List GitHub repository or organization secrets +description: + - List secrets in a GitHub repository or organization. +author: + - Thomas Sjögren (@konstruktoid) +version_added: '12.5.0' +requirements: + - pynacl +extends_documentation_fragment: + - community.general.attributes +attributes: + check_mode: + support: full + diff_mode: + support: none +options: + organization: + description: + - The GitHub username or organization name. + type: str + required: true + aliases: ["org", "username"] + repository: + description: + - The name of the repository. + - If not provided, the listing will be at organization level. + type: str + aliases: ["repo"] + api_url: + description: + - The base URL for the GitHub API. + type: str + default: "https://api.github.com" + token: + description: + - The GitHub token used for authentication. + type: str + required: true +""" + +EXAMPLES = r""" +- name: List Github secret + github_secrets_info: + token: "{{ lookup('env', 'GITHUB_TOKEN') }}" + repository: "ansible" + organization: "ansible" +""" + +RETURN = r""" +result: + description: The result of the module. + type: dict + returned: always + sample: { + "changed": false, + "failed": false, + "result": { + "info": 200, + "response": "Secrets listed", + "secrets": [ + { + "created_at": "2026-01-11T23:19:00Z", + "name": "ANSIBLE", + "updated_at": "2026-02-15T22:18:16Z" + }, + ] + }, + } +""" + +import json +from http import HTTPStatus +from typing import Any + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.urls import fetch_url + +from ansible_collections.community.general.plugins.module_utils import deps + +with deps.declare( + "pynacl", + reason="pynacl is a required dependency", + url="https://pypi.org/project/PyNaCl/", +): + pass + + +def list_secrets( + module: AnsibleModule, + api_url: str, + headers: dict[str, str], + organization: str, + repository: str, +) -> dict[str, int]: + url = ( + f"{api_url}/repos/{organization}/{repository}/actions/secrets" + if repository + else f"{api_url}/orgs/{organization}/actions/secrets" + ) + + resp, info = fetch_url(module, url, headers=headers, method="GET") + + if info["status"] == HTTPStatus.OK: + body = resp.read() + return { + "secrets": json.loads(body).get("secrets", []), + "info": info["status"], + "response": "Secrets listed", + } + elif info["status"] == HTTPStatus.NOT_FOUND: + return { + "secrets": [], + "info": info["status"], + "response": "No secrets found", + } + else: + module.fail_json(msg=f"Failed to list secrets: {info}") + + +def main() -> None: + """Ansible module entry point.""" + argument_spec = { + "organization": { + "type": "str", + "aliases": ["org", "username"], + "required": True, + }, + "repository": {"type": "str", "aliases": ["repo"]}, + "api_url": {"type": "str", "default": "https://api.github.com"}, + "token": {"type": "str", "required": True, "no_log": True}, + } + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + deps.validate(module) + + organization: str = module.params["organization"] + repository: str = module.params["repository"] + api_url: str = module.params["api_url"] + token: str = module.params["token"] + + result: dict[str, Any] = {} + + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + secrets = list_secrets(module, api_url, headers, organization, repository) + + result["changed"] = False + result.update( + result=secrets, + ) + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/unit/plugins/modules/test_github_secrets_info.py b/tests/unit/plugins/modules/test_github_secrets_info.py new file mode 100644 index 0000000000..e59729a1a6 --- /dev/null +++ b/tests/unit/plugins/modules/test_github_secrets_info.py @@ -0,0 +1,101 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest +from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( + AnsibleExitJson, + exit_json, + fail_json, + set_module_args, +) + +from ansible_collections.community.general.plugins.modules import github_secrets_info + +GITHUB_SECRETS_RESPONSE = { + "total_count": 2, + "secrets": [ + { + "name": "SECRET1", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z", + }, + { + "name": "SECRET2", + "created_at": "2026-01-03T00:00:00Z", + "updated_at": "2026-01-04T00:00:00Z", + }, + ], +} + + +def make_fetch_url_response(body, status=200): + response = MagicMock() + response.read.return_value = json.dumps(body).encode("utf-8") + info = {"status": status, "msg": f"OK ({len(json.dumps(body))} bytes)"} + return (response, info) + + +@pytest.fixture(autouse=True) +def patch_module(): + with patch.multiple( + "ansible.module_utils.basic.AnsibleModule", + exit_json=exit_json, + fail_json=fail_json, + ): + yield + + +@pytest.fixture +def fetch_url_mock(): + with patch.object(github_secrets_info, "fetch_url") as mock: + yield mock + + +def test_list_repo_secrets(fetch_url_mock): + fetch_url_mock.side_effect = [ + make_fetch_url_response(GITHUB_SECRETS_RESPONSE), + make_fetch_url_response({}, status=200), + ] + + with set_module_args( + { + "organization": "myorg", + "repository": "myrepo", + "token": "ghp_test_token", + } + ): + with pytest.raises(AnsibleExitJson) as exc: + github_secrets_info.main() + + result = exc.value.args[0] + assert result["changed"] is False + assert result["result"]["info"] == 200 + assert result["result"]["secrets"] == GITHUB_SECRETS_RESPONSE["secrets"] + + +def test_fail_list_repo_secrets(fetch_url_mock): + fetch_url_mock.side_effect = [ + make_fetch_url_response({}, status=404), + ] + + with set_module_args( + { + "organization": "myorg", + "repository": "myrepo", + "token": "ghp_test_token", + } + ): + with pytest.raises(AnsibleExitJson) as exc: + github_secrets_info.main() + + result = exc.value.args[0] + assert result["changed"] is False + assert result["result"]["info"] == 404 + assert result["result"]["secrets"] == [] + assert result["result"]["response"] == "No secrets found"