From 52cd104962fc8465d4890f8f29a626cc053c84ec Mon Sep 17 00:00:00 2001 From: YoussefKhalidAli <154611350+YoussefKhalidAli@users.noreply.github.com> Date: Tue, 24 Jun 2025 07:27:24 +0300 Subject: [PATCH] jenkins_credentials: new module to manage Jenkins credentials (#10170) * Added Jenkins credentials module to manage Jenkins credentials * Added Jenkins credentials module to manage Jenkins credentials * Added import error detection, adjusted indentation, and general enhancements. * Added py3 requirement and set files value to avoid errors * Added username to BOTMETA. Switched to format() instead of f strings to support py 2.7, improved delete function, and added function to read private key * Remove redundant message Co-authored-by: Felix Fontein * Replaced requests with ansible.module_utils.urls, merged check domain and credential functions, and made minor adjustments to documentation * Adjusted for py 2.7 compatibility * Replaced command with state. * Added managing credentials within a folder and made adjustments to documentation * Added unit and integration tests, added token managament, and adjusted documentation. * Added unit and integration tests, added token management, and adjusted documentation.(fix) * Fix BOTMETA.yml * Removed files and generate them at runtime. * moved id and token checks to required_if * Documentation changes, different test setup, and switched to Ansible testing tools * Fixed typos * Correct indentation. Co-authored-by: Felix Fontein --------- Co-authored-by: Felix Fontein --- .github/BOTMETA.yml | 2 + plugins/modules/jenkins_credential.py | 863 ++++++++++++++++++ .../targets/jenkins_credential/README.md | 16 + .../targets/jenkins_credential/aliases | 5 + .../jenkins_credential/docker-compose.yml | 21 + .../targets/jenkins_credential/tasks/add.yml | 169 ++++ .../targets/jenkins_credential/tasks/del.yml | 128 +++ .../targets/jenkins_credential/tasks/edit.yml | 192 ++++ .../targets/jenkins_credential/tasks/main.yml | 79 ++ .../targets/jenkins_credential/tasks/pre.yml | 92 ++ .../jenkins_credential/vars/credentials.yml | 6 + .../modules/test_jenkins_credential.py | 348 +++++++ 12 files changed, 1921 insertions(+) create mode 100644 plugins/modules/jenkins_credential.py create mode 100644 tests/integration/targets/jenkins_credential/README.md create mode 100644 tests/integration/targets/jenkins_credential/aliases create mode 100644 tests/integration/targets/jenkins_credential/docker-compose.yml create mode 100644 tests/integration/targets/jenkins_credential/tasks/add.yml create mode 100644 tests/integration/targets/jenkins_credential/tasks/del.yml create mode 100644 tests/integration/targets/jenkins_credential/tasks/edit.yml create mode 100644 tests/integration/targets/jenkins_credential/tasks/main.yml create mode 100644 tests/integration/targets/jenkins_credential/tasks/pre.yml create mode 100644 tests/integration/targets/jenkins_credential/vars/credentials.yml create mode 100644 tests/unit/plugins/modules/test_jenkins_credential.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index fc5f7abbdc..bdd0b4a629 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -781,6 +781,8 @@ files: maintainers: brettmilford unnecessary-username juanmcasanova $modules/jenkins_build_info.py: maintainers: juanmcasanova + $modules/jenkins_credential.py: + maintainers: YoussefKhalidAli $modules/jenkins_job.py: maintainers: sermilrod $modules/jenkins_job_info.py: diff --git a/plugins/modules/jenkins_credential.py b/plugins/modules/jenkins_credential.py new file mode 100644 index 0000000000..3aff19c96d --- /dev/null +++ b/plugins/modules/jenkins_credential.py @@ -0,0 +1,863 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# 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 absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: jenkins_credential +short_description: Manage Jenkins credentials and domains via API +version_added: 11.1.0 +description: + - This module allows managing Jenkins credentials and domain scopes via the Jenkins HTTP API. + - Create, update, and delete different credential types such as C(username/password), C(secret text), C(SSH key), C(certificates), C(GitHub App), and domains. + - For scoped domains (O(type=scope)), it supports restrictions based on V(hostname), V(hostname:port), V(path), and V(scheme). +requirements: + - urllib3 >= 1.26.0 +author: + - Youssef Ali (@YoussefKhalidAli) +extends_documentation_fragment: + - community.general.attributes +attributes: + check_mode: + support: full + diff_mode: + support: none +options: + id: + description: + - The ID of the Jenkins credential or domain. + type: str + type: + description: + - Type of the credential or action. + choices: + - user_and_pass + - file + - text + - github_app + - ssh_key + - certificate + - scope + - token + type: str + state: + description: + - The state of the credential. + choices: + - present + - absent + default: present + type: str + scope: + description: + - Jenkins credential domain scope. + - Deleting a domain scope deletes all credentials within it. + type: str + default: '_' + force: + description: + - Force update if the credential already exists, used with O(state=present). + - If set to V(true), it deletes the existing credential before creating a new one. + - Always returns RV(ignore:changed=true). + type: bool + default: false + url: + description: + - Jenkins server URL. + type: str + default: http://localhost:8080 + jenkins_user: + description: + - Jenkins user for authentication. + required: true + type: str + jenkins_password: + description: + - Jenkins password for token creation. Required if O(type=token). + type: str + token: + description: + - Jenkins API token. Required unless O(type=token). + type: str + description: + description: + - Description of the credential or domain. + default: '' + type: str + location: + description: + - Location of the credential. Either V(system) or V(folder). + - If O(location=folder) then O(url) must be set to V(/job/). + choices: + - system + - folder + default: 'system' + type: str + name: + description: + - Name of the token to generate. Required if O(type=token). + - When generating a new token, do not pass O(id). It is generated automatically. + - Creating two tokens with the same name generates two distinct tokens with different RV(token_uuid) values. + - Replacing a token with another one of the same name requires deleting the original first using O(force=True). + type: str + username: + description: + - Username for credentials types that require it (for example O(type=ssh_key) or O(type=user_and_pass)). + type: str + password: + description: + - Password for credentials types that require it (for example O(type=user_and_passs) or O(type=certificate)). + type: str + secret: + description: + - Secret text (used when O(type=text)). + type: str + appID: + description: + - GitHub App ID. + type: str + api_uri: + description: + - Link to Github API. + default: 'https://api.github.com' + type: str + owner: + description: + - GitHub App owner. + type: str + file_path: + description: + - File path to secret file (for example O(type=file) or O(type=certificate)). + - For O(type=certificate), this can be a V(.p12) or V(.pem) file. + type: path + private_key_path: + description: + - Path to private key file for PEM certificates or GitHub Apps. + type: path + passphrase: + description: + - SSH passphrase if needed. + type: str + inc_hostname: + description: + - List of hostnames to include in scope. + type: list + elements: str + exc_hostname: + description: + - List of hostnames to exclude from scope. + - If a hostname appears in both this list and O(inc_hostname), the hostname is excluded. + type: list + elements: str + inc_hostname_port: + description: + - List of V(host:port) to include in scope. + type: list + elements: str + exc_hostname_port: + description: + - List of host:port to exclude from scope. + - If a hostname and port appears in both this list and O(inc_hostname_port), it is excluded. + type: list + elements: str + inc_path: + description: + - List of URL paths to include when matching credentials to domains. + - "B(Matching is hierarchical): subpaths of excluded paths are also excluded, even if explicitly included." + type: list + elements: str + exc_path: + description: + - List of URL paths to exclude. + - If a path is also matched by O(exc_path), it is excluded. + - If you exclude a subpath of a path previously included, that subpath alone is excluded. + type: list + elements: str + schemes: + description: + - List of schemes (for example V(http) or V(https)) to match. + type: list + elements: str +""" + +EXAMPLES = r""" +- name: Generate token + community.general.jenkins_credential: + id: "test-token" + jenkins_user: "admin" + jenkins_password: "password" + type: "token" + register: token_result + +- name: Add CUSTOM scope credential + community.general.jenkins_credential: + id: "CUSTOM" + type: "scope" + jenkins_user: "admin" + token: "{{ token }}" + description: "Custom scope credential" + inc_path: + - "include/path" + - "include/path2" + exc_path: + - "exclude/path" + - "exclude/path2" + inc_hostname: + - "included-hostname" + - "included-hostname2" + exc_hostname: + - "excluded-hostname" + - "excluded-hostname2" + schemes: + - "http" + - "https" + inc_hostname_port: + - "included-hostname:7000" + - "included-hostname2:7000" + exc_hostname_port: + - "excluded-hostname:7000" + - "excluded-hostname2:7000" + +- name: Add user_and_pass credential + community.general.jenkins_credential: + id: "userpass-id" + type: "user_and_pass" + jenkins_user: "admin" + token: "{{ token }}" + description: "User and password credential" + username: "user1" + password: "pass1" + +- name: Add file credential to custom scope + community.general.jenkins_credential: + id: "file-id" + type: "file" + jenkins_user: "admin" + token: "{{ token }}" + scope: "CUSTOM" + description: "File credential" + file_path: "../vars/my-secret.pem" + +- name: Add text credential to folder + community.general.jenkins_credential: + id: "text-id" + type: "text" + jenkins_user: "admin" + token: "{{ token }}" + description: "Text credential" + secret: "mysecrettext" + location: "folder" + url: "http://localhost:8080/job/test" + +- name: Add githubApp credential + community.general.jenkins_credential: + id: "githubapp-id" + type: "github_app" + jenkins_user: "admin" + token: "{{ token }}" + description: "GitHub app credential" + appID: "12345" + file_path: "../vars/github.pem" + owner: "github_owner" + +- name: Add sshKey credential + community.general.jenkins_credential: + id: "sshkey-id" + type: "ssh_key" + jenkins_user: "admin" + token: "{{ token }}" + description: "SSH key credential" + username: "sshuser" + file_path: "../vars/ssh_key" + passphrase: 1234 + +- name: Add certificate credential (p12) + community.general.jenkins_credential: + id: "certificate-id" + type: "certificate" + jenkins_user: "admin" + token: "{{ token }}" + description: "Certificate credential" + password: "12345678901234" + file_path: "../vars/certificate.p12" + +- name: Add certificate credential (pem) + community.general.jenkins_credential: + id: "certificate-id-pem" + type: "certificate" + jenkins_user: "admin" + token: "{{ token }}" + description: "Certificate credential (pem)" + file_path: "../vars/cert.pem" + private_key_path: "../vars/private.key" +""" +RETURN = r""" +details: + description: Return more details in case of errors. + type: str + returned: failed +token: + description: + - The generated API token if O(type=token). + - This is needed to authenticate API calls later. + - This should be stored securely, as it is the only time it is returned. + type: str + returned: success +token_uuid: + description: + - The generated ID of the token. + - You pass this value back to the module as O(id) to edit or revoke the token later. + - This should be stored securely, as it is the only time it is returned. + type: str + returned: success +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.urls import fetch_url, basic_auth_header +from ansible.module_utils.six.moves.urllib.parse import urlencode +from ansible_collections.community.general.plugins.module_utils import deps + +import json +import os +import base64 + +with deps.declare("urllib3", reason="urllib3 is required to embed files into requests"): + import urllib3 + + +# Function to validate file paths exist on disk +def validate_file_exist(module, path): + + if path and not os.path.exists(path): + module.fail_json(msg="File not found: {}".format(path)) + + +# Gets the Jenkins crumb for CSRF protection which is required for API calls +def get_jenkins_crumb(module, headers): + type = module.params["type"] + url = module.params["url"] + + if "/job" in url: + url = url.split("/job")[0] + + crumb_url = "{}/crumbIssuer/api/json".format(url) + + response, info = fetch_url(module, crumb_url, headers=headers) + + if info["status"] != 200: + module.fail_json(msg="Failed to fetch Jenkins crumb. Confirm token is real.") + + # Cookie is needed to generate API token + cookie = info.get("set-cookie", "") + session_cookie = cookie.split(";")[0] if cookie else None + + try: + data = response.read() + json_data = json.loads(data) + crumb_request_field = json_data["crumbRequestField"] + crumb = json_data["crumb"] + headers[crumb_request_field] = crumb # Set the crumb in headers + headers["Content-Type"] = ( + "application/x-www-form-urlencoded" # Set Content-Type for form data + ) + if type == "token": + headers["Cookie"] = ( + session_cookie # Set session cookie for token operations + ) + return crumb_request_field, crumb, session_cookie # Return for test purposes + + except Exception: + return None + + +# Function to clean the data sent via API by removing unwanted keys and None values +def clean_data(data): + # Keys to remove (including those with None values) + keys_to_remove = { + "url", + "token", + "jenkins_user", + "jenkins_password", + "file_path", + "private_key_path", + "type", + "state", + "force", + "name", + "scope", + "location", + "api_uri", + } + + # Filter out None values and unwanted keys + cleaned_data = { + key: value + for key, value in data.items() + if value is not None and key not in keys_to_remove + } + + return cleaned_data + + +# Function to check if credentials/domain exists +def target_exists(module, check_domain=False): + url = module.params["url"] + location = module.params["location"] + scope = module.params["scope"] + name = module.params["id"] + user = module.params["jenkins_user"] + token = module.params["token"] + + headers = {"Authorization": basic_auth_header(user, token)} + + if module.params["type"] == "scope" or check_domain: + target_url = "{}/credentials/store/{}/domain/{}/api/json".format( + url, location, scope if check_domain else name + ) + elif module.params["type"] == "token": + return False # Can't check token + else: + target_url = "{}/credentials/store/{}/domain/{}/credential/{}/api/json".format( + url, location, scope, name + ) + + response, info = fetch_url(module, target_url, headers=headers) + status = info.get("status", 0) + + if status == 200: + return True + elif status == 404: + return False + else: + module.fail_json( + msg="Unexpected status code {} when checking {} existence.".format( + status, name + ) + ) + + +# Function to delete the scope or credential provided +def delete_target(module, headers): + user = module.params["jenkins_user"] + type = module.params["type"] + url = module.params["url"] + location = module.params["location"] + id = module.params["id"] + scope = module.params["scope"] + + body = False + + try: + + if type == "token": + delete_url = "{}/user/{}/descriptorByName/jenkins.security.ApiTokenProperty/revoke".format( + url, user + ) + body = urlencode({"tokenUuid": id}) + + elif type == "scope": + delete_url = "{}/credentials/store/{}/domain/{}/doDelete".format( + url, location, id + ) + + else: + delete_url = ( + "{}/credentials/store/{}/domain/{}/credential/{}/doDelete".format( + url, location, scope, id + ) + ) + + response, info = fetch_url( + module, + delete_url, + headers=headers, + data=body if body else None, + method="POST", + ) + + status = info.get("status", 0) + if not status == 200: + module.fail_json( + msg="Failed to delete: HTTP {}, {}, {}".format( + status, response, headers + ) + ) + + except Exception as e: + module.fail_json(msg="Exception during delete: {}".format(str(e))) + + +# Function to read the private key for types texts and ssh_key +def read_privateKey(module): + try: + with open(module.params["private_key_path"], "r") as f: + private_key = f.read().strip() + return private_key + except Exception as e: + module.fail_json(msg="Failed to read private key file: {}".format(str(e))) + + +# Function to builds multipart form-data body and content-type header for file credential upload. +# Returns: +# body (bytes): Encoded multipart data +# content_type (str): Content-Type header including boundary +def embed_file_into_body(module, file_path, credentials): + + filename = os.path.basename(file_path) + + try: + with open(file_path, "rb") as f: + file_bytes = f.read() + except Exception as e: + module.fail_json(msg="Failed to read file: {}".format(str(e))) + return "", "" # Return for test purposes + + credentials.update( + { + "file": "file0", + "fileName": filename, + } + ) + + payload = {"credentials": credentials} + + fields = {"file0": (filename, file_bytes), "json": json.dumps(payload)} + + body, content_type = urllib3.encode_multipart_formdata(fields) + return body, content_type + + +# Main function to run the Ansible module +def run_module(): + + module = AnsibleModule( + argument_spec=dict( + id=dict(type="str"), + type=dict( + type="str", + choices=[ + "user_and_pass", + "file", + "text", + "github_app", + "ssh_key", + "certificate", + "scope", + "token", + ], + ), + state=dict(type="str", default="present", choices=["present", "absent"]), + force=dict(type="bool", default=False), + scope=dict(type="str", default="_"), + url=dict(type="str", default="http://localhost:8080"), + jenkins_user=dict(type="str", required=True), + jenkins_password=dict(type="str", no_log=True), + token=dict(type="str", no_log=True), + description=dict(type="str", default=""), + location=dict(type="str", default="system", choices=["system", "folder"]), + name=dict(type="str"), + username=dict(type="str"), + password=dict(type="str", no_log=True), + file_path=dict(type="path", default=None), + secret=dict(type="str", no_log=True), + appID=dict(type="str"), + api_uri=dict(type="str", default="https://api.github.com"), + owner=dict(type="str"), + passphrase=dict(type="str", no_log=True), + private_key_path=dict(type="path", no_log=True), + # Scope specifications parameters + inc_hostname=dict(type="list", elements="str"), + exc_hostname=dict(type="list", elements="str"), + inc_hostname_port=dict(type="list", elements="str"), + exc_hostname_port=dict(type="list", elements="str"), + inc_path=dict(type="list", elements="str"), + exc_path=dict(type="list", elements="str"), + schemes=dict(type="list", elements="str"), + ), + supports_check_mode=True, + required_if=[ + ("state", "present", ["type"]), + ("state", "absent", ["id"]), + ("type", "token", ["name", "jenkins_password"]), + ("type", "user_and_pass", ["username", "password", "id", "token"]), + ("type", "file", ["file_path", "id", "token"]), + ("type", "text", ["secret", "id", "token"]), + ("type", "github_app", ["appID", "private_key_path", "id", "token"]), + ("type", "ssh_key", ["username", "private_key_path", "id", "token"]), + ("type", "certificate", ["file_path", "id", "token"]), + ("type", "scope", ["id", "token"]), + ], + ) + + # Parameters + id = module.params["id"] + type = module.params["type"] + state = module.params["state"] + force = module.params["force"] + scope = module.params["scope"] + url = module.params["url"] + jenkins_user = module.params["jenkins_user"] + jenkins_password = module.params["jenkins_password"] + name = module.params["name"] + token = module.params["token"] + description = module.params["description"] + location = module.params["location"] + filePath = module.params["file_path"] + private_key_path = module.params["private_key_path"] + api_uri = module.params["api_uri"] + inc_hostname = module.params["inc_hostname"] + exc_hostname = module.params["exc_hostname"] + inc_hostname_port = module.params["inc_hostname_port"] + exc_hostname_port = module.params["exc_hostname_port"] + inc_path = module.params["inc_path"] + exc_path = module.params["exc_path"] + schemes = module.params["schemes"] + + deps.validate(module) + + headers = { + "Authorization": basic_auth_header(jenkins_user, token or jenkins_password), + } + + # Get the crumb for CSRF protection + get_jenkins_crumb(module, headers) + + result = dict( + changed=False, + msg="", + ) + + credentials = clean_data(module.params) + + does_exist = target_exists(module) + + # Check if the credential/domain doesn't exist and the user wants to delete + if not does_exist and state == "absent" and not type == "token": + result["changed"] = False + result["msg"] = "{} does not exist.".format(id) + module.exit_json(**result) + + if state == "present": + + # If updating, we need to delete the existing credential/domain first based on force parameter + if force and (does_exist or type == "token"): + delete_target(module, headers) + elif does_exist and not force: + result["changed"] = False + result["msg"] = "{} already exists. Use force=True to update.".format(id) + module.exit_json(**result) + + if type == "token": + + post_url = "{}/user/{}/descriptorByName/jenkins.security.ApiTokenProperty/generateNewToken".format( + url, jenkins_user + ) + + body = "newTokenName={}".format(name) + + elif type == "scope": + + post_url = "{}/credentials/store/{}/createDomain".format(url, location) + + specifications = [] + + # Create a domain in Jenkins + if inc_hostname or exc_hostname: + specifications.append( + { + "stapler-class": "com.cloudbees.plugins.credentials.domains.HostnameSpecification", + "includes": ",".join(inc_hostname), + "excludes": ",".join(exc_hostname), + } + ) + + if inc_hostname_port or exc_hostname_port: + specifications.append( + { + "stapler-class": "com.cloudbees.plugins.credentials.domains.HostnamePortSpecification", + "includes": ",".join(inc_hostname_port), + "excludes": ",".join(exc_hostname_port), + } + ) + + if schemes: + specifications.append( + { + "stapler-class": "com.cloudbees.plugins.credentials.domains.SchemeSpecification", + "schemes": ",".join(schemes), + }, + ) + + if inc_path or exc_path: + specifications.append( + { + "stapler-class": "com.cloudbees.plugins.credentials.domains.PathSpecification", + "includes": ",".join(inc_path), + "excludes": ",".join(exc_path), + } + ) + + payload = { + "name": id, + "description": description, + "specifications": specifications, + } + + else: + if filePath: + validate_file_exist(module, filePath) + elif private_key_path: + validate_file_exist(module, private_key_path) + + post_url = "{}/credentials/store/{}/domain/{}/createCredentials".format( + url, location, scope + ) + + cred_class = { + "user_and_pass": "com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl", + "file": "org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl", + "text": "org.jenkinsci.plugins.plaincredentials.impl.StringCredentialsImpl", + "github_app": "org.jenkinsci.plugins.github_branch_source.GitHubAppCredentials", + "ssh_key": "com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey", + "certificate": "com.cloudbees.plugins.credentials.impl.CertificateCredentialsImpl", + } + credentials.update({"$class": cred_class[type]}) + + if type == "file": + + # Build multipart body and content-type + body, content_type = embed_file_into_body(module, filePath, credentials) + headers["Content-Type"] = content_type + + elif type == "github_app": + + private_key = read_privateKey(module) + + credentials.update( + { + "privateKey": private_key, + "apiUri": api_uri, + } + ) + + elif type == "ssh_key": + + private_key = read_privateKey(module) + + credentials.update( + { + "privateKeySource": { + "stapler-class": "com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey$DirectEntryPrivateKeySource", + "privateKey": private_key, + }, + } + ) + + elif type == "certificate": + + name, ext = os.path.splitext(filePath) + + if ext.lower() in [".p12", ".pfx"]: + try: + with open(filePath, "rb") as f: + file_content = f.read() + uploaded_keystore = base64.b64encode(file_content).decode( + "utf-8" + ) + except Exception as e: + module.fail_json( + msg="Failed to read or encode keystore file: {}".format( + str(e) + ) + ) + + credentials.update( + { + "keyStoreSource": { + "$class": "com.cloudbees.plugins.credentials.impl.CertificateCredentialsImpl$UploadedKeyStoreSource", + "uploadedKeystore": uploaded_keystore, + }, + } + ) + + elif ext.lower() in [".pem", ".crt"]: # PEM mode + try: + with open(filePath, "r") as f: + cert_chain = f.read() + with open(private_key_path, "r") as f: + private_key = f.read() + except Exception as e: + module.fail_json( + msg="Failed to read PEM files: {}".format(str(e)) + ) + + credentials.update( + { + "keyStoreSource": { + "$class": "com.cloudbees.plugins.credentials.impl.CertificateCredentialsImpl$PEMEntryKeyStoreSource", + "certChain": cert_chain, + "privateKey": private_key, + }, + } + ) + + else: + module.fail_json( + msg="Unsupported certificate file type. Only .p12, .pfx, .pem or .crt are supported." + ) + + payload = {"credentials": credentials} + + if not type == "file" and not type == "token": + body = urlencode({"json": json.dumps(payload)}) + + else: # Delete + + delete_target(module, headers) + + module.exit_json(changed=True, msg="{} deleted successfully.".format(id)) + + if ( + not type == "scope" and not scope == "_" + ): # Check if custom scope exists if adding to a custom scope + if not target_exists(module, True): + module.fail_json(msg="Domain {} doesn't exists".format(scope)) + + try: + response, info = fetch_url( + module, post_url, headers=headers, data=body, method="POST" + ) + except Exception as e: + module.fail_json(msg="Request to {} failed: {}".format(post_url, str(e))) + + status = info.get("status", 0) + + if not status == 200: + body = response.read() if response else b"" + module.fail_json( + msg="Failed to {} credential".format( + "add/update" if state == "present" else "delete" + ), + details=body.decode("utf-8", errors="ignore"), + ) + + if type == "token": + response_data = json.loads(response.read()) + result["token"] = response_data["data"]["tokenValue"] + result["token_uuid"] = response_data["data"]["tokenUuid"] + + result["changed"] = True + result["msg"] = response.read().decode("utf-8") + + module.exit_json(**result) + + +if __name__ == "__main__": + run_module() diff --git a/tests/integration/targets/jenkins_credential/README.md b/tests/integration/targets/jenkins_credential/README.md new file mode 100644 index 0000000000..3b1dc74c15 --- /dev/null +++ b/tests/integration/targets/jenkins_credential/README.md @@ -0,0 +1,16 @@ + + +The integration test can be performed as follows: + +``` +# 1. Start docker-compose: +docker-compose -f tests/integration/targets/jenkins_credential/docker-compose.yml down +docker-compose -f tests/integration/targets/jenkins_credential/docker-compose.yml up -d + +# 2. Run the integration tests: +ansible-test integration jenkins_credential --allow-unsupported -v +``` diff --git a/tests/integration/targets/jenkins_credential/aliases b/tests/integration/targets/jenkins_credential/aliases new file mode 100644 index 0000000000..d2086eecf8 --- /dev/null +++ b/tests/integration/targets/jenkins_credential/aliases @@ -0,0 +1,5 @@ +# 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 + +unsupported \ No newline at end of file diff --git a/tests/integration/targets/jenkins_credential/docker-compose.yml b/tests/integration/targets/jenkins_credential/docker-compose.yml new file mode 100644 index 0000000000..c99c9ed575 --- /dev/null +++ b/tests/integration/targets/jenkins_credential/docker-compose.yml @@ -0,0 +1,21 @@ +# 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 + +version: "3.8" + +services: + jenkins: + image: bitnami/jenkins + container_name: jenkins-test + ports: + - "8080:8080" + environment: + JENKINS_USERNAME: "FishLegs" + JENKINS_PASSWORD: "MeatLug" + JENKINS_PLUGINS: "credentials,cloudbees-folder,plain-credentials,github-branch-source,github-api,scm-api,workflow-step-api" + healthcheck: + test: curl -s http://localhost:8080/login || exit 1 + interval: 10s + timeout: 10s + retries: 10 diff --git a/tests/integration/targets/jenkins_credential/tasks/add.yml b/tests/integration/targets/jenkins_credential/tasks/add.yml new file mode 100644 index 0000000000..c956773454 --- /dev/null +++ b/tests/integration/targets/jenkins_credential/tasks/add.yml @@ -0,0 +1,169 @@ +# 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 + +- name: Add CUSTOM scope (run {{ run_number }}) + community.general.jenkins_credential: + id: "CUSTOM" + type: "scope" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + description: "Custom scope credential" + inc_path: + - "include/path" + - "include/path2" + exc_path: + - "exclude/path" + - "exclude/path2" + inc_hostname: + - "included-hostname" + - "included-hostname2" + exc_hostname: + - "excluded-hostname" + - "excluded-hostname2" + schemes: + - "http" + - "https" + inc_hostname_port: + - "included-hostname:7000" + - "included-hostname2:7000" + exc_hostname_port: + - "excluded-hostname:7000" + - "excluded-hostname2:7000" + register: custom_scope + +- name: Assert CUSTOM scope changed value + assert: + that: + - custom_scope.changed == (run_number == 1) + fail_msg: "CUSTOM scope changed status incorrect on run {{ run_number }}" + success_msg: "CUSTOM scope behaved correctly on run {{ run_number }}" + +- name: Add user_and_pass credential (run {{ run_number }}) + community.general.jenkins_credential: + id: "userpass-id" + type: "user_and_pass" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + description: "User and password credential" + username: "user1" + password: "pass1" + register: userpass_cred + +- name: Assert user_and_pass changed value + assert: + that: + - userpass_cred.changed == (run_number == 1) + fail_msg: "user_and_pass credential changed status incorrect on run {{ run_number }}" + success_msg: "user_and_pass credential behaved correctly on run {{ run_number }}" + +- name: Add file credential to custom scope (run {{ run_number }}) + community.general.jenkins_credential: + id: "file-id" + type: "file" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + scope: "CUSTOM" + description: "File credential" + file_path: "{{ output_dir }}/my-secret.pem" + register: file_cred + +- name: Assert file credential changed value + assert: + that: + - file_cred.changed == (run_number == 1) + fail_msg: "file credential changed status incorrect on run {{ run_number }}" + success_msg: "file credential behaved correctly on run {{ run_number }}" + +- name: Add text credential to folder (run {{ run_number }}) + community.general.jenkins_credential: + id: "text-id" + type: "text" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + description: "Text credential" + secret: "mysecrettext" + location: "folder" + url: "http://localhost:8080/job/test" + register: text_cred + +- name: Assert text credential changed value + assert: + that: + - text_cred.changed == (run_number == 1) + fail_msg: "text credential changed status incorrect on run {{ run_number }}" + success_msg: "text credential behaved correctly on run {{ run_number }}" + +- name: Add githubApp credential (run {{ run_number }}) + community.general.jenkins_credential: + id: "githubapp-id" + type: "github_app" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + description: "GitHub app credential" + appID: "12345" + private_key_path: "{{ output_dir }}/github.pem" + owner: "github_owner" + register: githubapp_cred + +- name: Assert githubApp credential changed value + assert: + that: + - githubapp_cred.changed == (run_number == 1) + fail_msg: "githubApp credential changed status incorrect on run {{ run_number }}" + success_msg: "githubApp credential behaved correctly on run {{ run_number }}" + +- name: Add sshKey credential (run {{ run_number }}) + community.general.jenkins_credential: + id: "sshkey-id" + type: "ssh_key" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + description: "SSH key credential" + username: "sshuser" + private_key_path: "{{ output_dir }}/ssh_key" + passphrase: 1234 + register: sshkey_cred + +- name: Assert sshKey credential changed value + assert: + that: + - sshkey_cred.changed == (run_number == 1) + fail_msg: "sshKey credential changed status incorrect on run {{ run_number }}" + success_msg: "sshKey credential behaved correctly on run {{ run_number }}" + +- name: Add certificate (p12) credential (run {{ run_number }}) + community.general.jenkins_credential: + id: "certificate-id" + type: "certificate" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + description: "Certificate credential" + password: "12345678901234" + file_path: "{{ output_dir }}/certificate.p12" + register: cert_p12_cred + +- name: Assert certificate (p12) credential changed value + assert: + that: + - cert_p12_cred.changed == (run_number == 1) + fail_msg: "certificate (p12) credential changed status incorrect on run {{ run_number }}" + success_msg: "certificate (p12) credential behaved correctly on run {{ run_number }}" + +- name: Add certificate (pem) credential (run {{ run_number }}) + community.general.jenkins_credential: + id: "certificate-id-pem" + type: "certificate" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + description: "Certificate credential (pem)" + file_path: "{{ output_dir }}/cert.pem" + private_key_path: "{{ output_dir }}/private.key" + register: cert_pem_cred + +- name: Assert certificate (pem) credential changed value + assert: + that: + - cert_pem_cred.changed == (run_number == 1) + fail_msg: "certificate (pem) credential changed status incorrect on run {{ run_number }}" + success_msg: "certificate (pem) credential behaved correctly on run {{ run_number }}" diff --git a/tests/integration/targets/jenkins_credential/tasks/del.yml b/tests/integration/targets/jenkins_credential/tasks/del.yml new file mode 100644 index 0000000000..036b65d3a1 --- /dev/null +++ b/tests/integration/targets/jenkins_credential/tasks/del.yml @@ -0,0 +1,128 @@ +# 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 + +- name: Delete user_and_pass credential (run {{ run_number }}) + community.general.jenkins_credential: + id: "userpass-id" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + state: "absent" + register: userpass_cred + +- name: Assert user_and_pass changed value + assert: + that: + - userpass_cred.changed == (run_number == 1) + fail_msg: "user_and_pass credential changed status incorrect on run {{ run_number }}" + success_msg: "user_and_pass credential behaved correctly on run {{ run_number }}" + +- name: Delete file credential to custom scope (run {{ run_number }}) + community.general.jenkins_credential: + id: "file-id" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + scope: "CUSTOM" + state: "absent" + register: file_cred + +- name: Assert file credential changed value + assert: + that: + - file_cred.changed == (run_number == 1) + fail_msg: "file credential changed status incorrect on run {{ run_number }}" + success_msg: "file credential behaved correctly on run {{ run_number }}" + +- name: Delete CUSTOM scope credential (run {{ run_number}}) + community.general.jenkins_credential: + id: "CUSTOM" + type: "scope" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + state: "absent" + register: custom_scope + +- name: Assert CUSTOM scope changed value + assert: + that: + - custom_scope.changed == (run_number == 1) + fail_msg: "CUSTOM scope changed status incorrect on run {{ run_number }}" + success_msg: "CUSTOM scope behaved correctly on run {{ run_number }}" + +- name: Delete text credential to folder (run {{ run_number }}) + community.general.jenkins_credential: + id: "text-id" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + state: "absent" + location: "folder" + url: "http://localhost:8080/job/test" + register: text_cred + +- name: Assert text credential changed value + assert: + that: + - text_cred.changed == (run_number == 1) + fail_msg: "text credential changed status incorrect on run {{ run_number }}" + success_msg: "text credential behaved correctly on run {{ run_number }}" + +- name: Delete githubApp credential (run {{ run_number }}) + community.general.jenkins_credential: + id: "githubapp-id" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + state: "absent" + register: githubapp_cred + +- name: Assert githubApp credential changed value + assert: + that: + - githubapp_cred.changed == (run_number == 1) + fail_msg: "githubApp credential changed status incorrect on run {{ run_number }}" + success_msg: "githubApp credential behaved correctly on run {{ run_number }}" + +- name: Delete sshKey credential (run {{ run_number }}) + community.general.jenkins_credential: + id: "sshkey-id" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + description: "SSH key credential" + state: "absent" + register: sshkey_cred + +- name: Assert sshKey credential changed value + assert: + that: + - sshkey_cred.changed == (run_number == 1) + fail_msg: "sshKey credential changed status incorrect on run {{ run_number }}" + success_msg: "sshKey credential behaved correctly on run {{ run_number }}" + +- name: Delete certificate credential (p12) (run {{ run_number }}) + community.general.jenkins_credential: + id: "certificate-id" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + state: "absent" + register: cert_p12_cred + +- name: Assert certificate (p12) credential changed value + assert: + that: + - cert_p12_cred.changed == (run_number == 1) + fail_msg: "certificate (p12) credential changed status incorrect on run {{ run_number }}" + success_msg: "certificate (p12) credential behaved correctly on run {{ run_number }}" + +- name: Delete certificate credential (pem) (run {{ run_number }}) + community.general.jenkins_credential: + id: "certificate-id-pem" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + state: "absent" + register: cert_pem_cred + +- name: Assert certificate (pem) credential changed value + assert: + that: + - cert_pem_cred.changed == (run_number == 1) + fail_msg: "certificate (pem) credential changed status incorrect on run {{ run_number }}" + success_msg: "certificate (pem) credential behaved correctly on run {{ run_number }}" diff --git a/tests/integration/targets/jenkins_credential/tasks/edit.yml b/tests/integration/targets/jenkins_credential/tasks/edit.yml new file mode 100644 index 0000000000..e8584881f9 --- /dev/null +++ b/tests/integration/targets/jenkins_credential/tasks/edit.yml @@ -0,0 +1,192 @@ +# 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 + +- name: Generate token + community.general.jenkins_credential: + id: "{{ tokenUuid}}" + name: "test-token-2" + jenkins_user: "{{ jenkins_username }}" + jenkins_password: "{{ jenkins_password }}" + type: "token" + force: yes + register: token_result + +- name: Set token in vars + set_fact: + token: "{{ token_result.token }}" + tokenUuid: "{{ token_result.token_uuid }}" + +- name: Edit CUSTOM scope credential + community.general.jenkins_credential: + id: "CUSTOM" + type: "scope" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + description: "New custom scope credential" + inc_path: + - "new_include/path" + - "new_include/path2" + exc_path: + - "new_exclude/path" + - "new_exclude/path2" + inc_hostname: + - "new_included-hostname" + - "new_included-hostname2" + exc_hostname: + - "new_excluded-hostname" + - "new_excluded-hostname2" + schemes: + - "new_http" + - "new_https" + inc_hostname_port: + - "new_included-hostname:7000" + - "new_included-hostname2:7000" + exc_hostname_port: + - "new_excluded-hostname:7000" + - "new_excluded-hostname2:7000" + force: yes + register: custom_scope + +- name: Assert CUSTOM scope changed value + assert: + that: + - custom_scope.changed == true + fail_msg: "CUSTOM scope changed status when it shouldn't" + success_msg: "CUSTOM scope behaved correctly" + +- name: Edit user_and_pass credential + community.general.jenkins_credential: + id: "userpass-id" + type: "user_and_pass" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + description: "new user and password credential" + username: "user2" + password: "pass2" + force: yes + register: userpass_cred + +- name: Assert user_and_pass changed value + assert: + that: + - userpass_cred.changed == true + fail_msg: "user_and_pass credential changed status incorrect" + success_msg: "user_and_pass credential behaved correctly" + +- name: Edit file credential to custom scope + community.general.jenkins_credential: + id: "file-id" + type: "file" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + scope: "CUSTOM" + description: "New file credential" + file_path: "{{ output_dir }}/my-secret.pem" + force: yes + register: file_cred + +- name: Assert file credential changed value + assert: + that: + - file_cred.changed == true + fail_msg: "file credential changed status incorrect" + success_msg: "file credential behaved correctly" + +- name: Edit text credential to folder + community.general.jenkins_credential: + id: "text-id" + type: "text" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + description: "New text credential" + secret: "mynewsecrettext" + location: "folder" + url: "http://localhost:8080/job/test" + force: yes + register: text_cred + +- name: Assert text credential changed value + assert: + that: + - text_cred.changed == true + fail_msg: "text credential changed status incorrect" + success_msg: "text credential behaved correctly" + +- name: Edit githubApp credential + community.general.jenkins_credential: + id: "githubapp-id" + type: "github_app" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + description: "New GitHub app credential" + appID: "12345678" + private_key_path: "{{ output_dir }}/github.pem" + owner: "new_github_owner" + force: yes + register: githubapp_cred + +- name: Assert githubApp credential changed value + assert: + that: + - githubapp_cred.changed == true + fail_msg: "githubApp credential changed status incorrect" + success_msg: "githubApp credential behaved correctly" + +- name: Edit sshKey credential + community.general.jenkins_credential: + id: "sshkey-id" + type: "ssh_key" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + description: "New SSH key credential" + username: "new_sshuser" + private_key_path: "{{ output_dir }}/ssh_key" + passphrase: 1234 + force: yes + register: sshkey_cred + +- name: Assert sshKey credential changed value + assert: + that: + - sshkey_cred.changed == true + fail_msg: "sshKey credential changed status incorrect" + success_msg: "sshKey credential behaved correctly" + +- name: Edit certificate credential (p12) + community.general.jenkins_credential: + id: "certificate-id" + type: "certificate" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + description: "New certificate credential" + password: "12345678901234" + file_path: "{{ output_dir }}/certificate.p12" + force: yes + register: cert_p12_cred + +- name: Assert certificate (p12) credential changed value + assert: + that: + - cert_p12_cred.changed == true + fail_msg: "certificate (p12) credential changed status incorrect" + success_msg: "certificate (p12) credential behaved correctly" + +- name: Edit certificate credential (pem) + community.general.jenkins_credential: + id: "certificate-id-pem" + type: "certificate" + jenkins_user: "{{ jenkins_username }}" + token: "{{ token }}" + description: "New certificate credential (pem)" + file_path: "{{ output_dir }}/cert.pem" + private_key_path: "{{ output_dir }}/private.key" + force: yes + register: cert_pem_cred + +- name: Assert certificate (pem) credential changed value + assert: + that: + - cert_pem_cred.changed == true + fail_msg: "certificate (pem) credential changed status incorrect" + success_msg: "certificate (pem) credential behaved correctly" diff --git a/tests/integration/targets/jenkins_credential/tasks/main.yml b/tests/integration/targets/jenkins_credential/tasks/main.yml new file mode 100644 index 0000000000..b2de6cd39e --- /dev/null +++ b/tests/integration/targets/jenkins_credential/tasks/main.yml @@ -0,0 +1,79 @@ +# 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 + +- name: Prepare the test environment + include_tasks: pre.yml + vars: + output_dir: "{{ playbook_dir }}/generated" + +- name: Generate token + community.general.jenkins_credential: + name: "test-token" + jenkins_user: "{{ jenkins_username }}" + jenkins_password: "{{ jenkins_password }}" + type: "token" + no_log: yes + register: token_result + +- name: Assert token and tokenUuid are returned + assert: + that: + - token_result.token is defined + - token_result.token_uuid is defined + fail_msg: "Token generation failed" + success_msg: "Token and tokenUuid successfully returned" + +- name: Set token facts + set_fact: + token: "{{ token_result.token }}" + tokenUuid: "{{ token_result.token_uuid }}" + +- name: Test adding new credentials and scopes + include_tasks: add.yml + vars: + run_number: 1 + output_dir: "{{ playbook_dir }}/generated" + +- name: Test adding credentials and scopes when they already exist + include_tasks: add.yml + vars: + run_number: 2 + output_dir: "{{ playbook_dir }}/generated" + +- name: Test editing credentials + include_tasks: edit.yml + vars: + output_dir: "{{ playbook_dir }}/generated" + +- name: Test deleting credentials and scopes + include_tasks: del.yml + vars: + run_number: 1 + +- name: Test deleting credentials and scopes when they don't exist + include_tasks: del.yml + vars: + run_number: 2 + +- name: Delete token + community.general.jenkins_credential: + id: "{{ tokenUuid }}" + name: "test-token-2" + jenkins_user: "{{ jenkins_username }}" + jenkins_password: "{{ jenkins_password }}" + state: "absent" + type: "token" + register: delete_token_result + +- name: Assert token deletion + assert: + that: + - delete_token_result.changed is true + fail_msg: "Token deletion failed" + success_msg: "Token successfully deleted" + +- name: Remove generated test files + ansible.builtin.file: + path: "{{ playbook_dir }}/generated" + state: absent diff --git a/tests/integration/targets/jenkins_credential/tasks/pre.yml b/tests/integration/targets/jenkins_credential/tasks/pre.yml new file mode 100644 index 0000000000..b55acdf6a8 --- /dev/null +++ b/tests/integration/targets/jenkins_credential/tasks/pre.yml @@ -0,0 +1,92 @@ +# 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 + +- name: Include Jenkins user variables + include_vars: "{{ role_path }}/vars/credentials.yml" + +- name: Make sure Jenkins is ready + uri: + url: http://localhost:8080/login + status_code: 200 + return_content: no + timeout: 30 + register: result + retries: 10 + delay: 5 + until: result.status == 200 + +- name: Get Jenkins crumb and save cookie + shell: | + curl -s -c cookies.txt -u FishLegs:MeatLug http://localhost:8080/crumbIssuer/api/json > crumb.json + args: + executable: /bin/bash + +- name: Read crumb value + set_fact: + crumb_data: "{{ lookup('file', 'crumb.json') | from_json }}" + +- name: Create Jenkins folder 'test' + shell: | + curl -b cookies.txt -u {{ jenkins_username }}:{{ jenkins_password }} \ + -H "{{ crumb_data.crumbRequestField }}: {{ crumb_data.crumb }}" \ + -H "Content-Type: application/xml" \ + --data-binary @- http://localhost:8080/createItem?name=test < + Test Folder + + + EOF + args: + executable: /bin/bash + +- name: Create output directory + ansible.builtin.file: + path: "{{ output_dir }}" + state: directory + mode: "0755" + +- name: Generate private key + community.crypto.openssl_privatekey: + path: "{{ output_dir }}/private.key" + size: 2048 + type: RSA + +- name: Generate CSR (certificate signing request) + community.crypto.openssl_csr: + path: "{{ output_dir }}/request.csr" + privatekey_path: "{{ output_dir }}/private.key" + common_name: "dummy.local" + +- name: Generate self-signed certificate + community.crypto.x509_certificate: + path: "{{ output_dir }}/cert.pem" + privatekey_path: "{{ output_dir }}/private.key" + csr_path: "{{ output_dir }}/request.csr" + provider: selfsigned + +- name: Create PKCS#12 (.p12) file + community.crypto.openssl_pkcs12: + path: "{{ output_dir }}/certificate.p12" + privatekey_path: "{{ output_dir }}/private.key" + certificate_path: "{{ output_dir }}/cert.pem" + friendly_name: "dummy-cert" + passphrase: "12345678901234" + +- name: Copy cert.pem to github.pem + ansible.builtin.copy: + src: "{{ output_dir }}/cert.pem" + dest: "{{ output_dir }}/github.pem" + remote_src: true + +- name: Copy private.key to my-secret.pem + ansible.builtin.copy: + src: "{{ output_dir }}/private.key" + dest: "{{ output_dir }}/my-secret.pem" + remote_src: true + +- name: Generate dummy SSH key + community.crypto.openssh_keypair: + path: "{{ output_dir }}/ssh_key" + type: rsa + size: 2048 diff --git a/tests/integration/targets/jenkins_credential/vars/credentials.yml b/tests/integration/targets/jenkins_credential/vars/credentials.yml new file mode 100644 index 0000000000..27df98700b --- /dev/null +++ b/tests/integration/targets/jenkins_credential/vars/credentials.yml @@ -0,0 +1,6 @@ +# 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 + +jenkins_username: FishLegs +jenkins_password: MeatLug diff --git a/tests/unit/plugins/modules/test_jenkins_credential.py b/tests/unit/plugins/modules/test_jenkins_credential.py new file mode 100644 index 0000000000..b74b7c4b59 --- /dev/null +++ b/tests/unit/plugins/modules/test_jenkins_credential.py @@ -0,0 +1,348 @@ +# 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 absolute_import, division, print_function + +__metaclass__ = type + +from ansible_collections.community.general.plugins.modules import jenkins_credential +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import ( + MagicMock, + patch, + mock_open, +) + +import json +import sys + +if sys.version_info[0] == 3: + import builtins + open_path = "builtins.open" +else: + import __builtin__ as builtins + open_path = "__builtin__.open" + + +def test_validate_file_exist_passes_when_file_exists(): + module = MagicMock() + with patch("os.path.exists", return_value=True): + jenkins_credential.validate_file_exist(module, "/some/file/path") + module.fail_json.assert_not_called() + + +def test_validate_file_exist_fails_when_file_missing(): + module = MagicMock() + with patch("os.path.exists", return_value=False): + jenkins_credential.validate_file_exist(module, "/missing/file/path") + module.fail_json.assert_called_once_with( + msg="File not found: /missing/file/path" + ) + + +@patch( + "ansible_collections.community.general.plugins.modules.jenkins_credential.fetch_url" +) +def test_get_jenkins_crumb_sets_crumb_header(fetch_mock): + module = MagicMock() + module.params = {"type": "file", "url": "http://localhost:8080"} + headers = {} + + fake_response = MagicMock() + fake_response.read.return_value = json.dumps( + {"crumbRequestField": "crumb_field", "crumb": "abc123"} + ).encode("utf-8") + + fetch_mock.return_value = ( + fake_response, + {"status": 200, "set-cookie": "JSESSIONID=something; Path=/"}, + ) + + crumb_request_field, crumb, session_coockie = jenkins_credential.get_jenkins_crumb( + module, headers + ) + + assert "Cookie" not in headers + assert "crumb_field" in headers + assert crumb == "abc123" + assert headers[crumb_request_field] == crumb + + +@patch( + "ansible_collections.community.general.plugins.modules.jenkins_credential.fetch_url" +) +def test_get_jenkins_crumb_sets_cookie_if_type_token(fetch_mock): + module = MagicMock() + module.params = {"type": "token", "url": "http://localhost:8080"} + headers = {} + + fake_response = MagicMock() + fake_response.read.return_value = json.dumps( + {"crumbRequestField": "crumb_field", "crumb": "secure"} + ).encode("utf-8") + + fetch_mock.return_value = ( + fake_response, + {"status": 200, "set-cookie": "JSESSIONID=token-cookie; Path=/"}, + ) + + crumb_request_field, crumb, session_cookie = jenkins_credential.get_jenkins_crumb( + module, headers + ) + + assert "crumb_field" in headers + assert crumb == "secure" + assert headers[crumb_request_field] == crumb + assert headers["Cookie"] == session_cookie + + +@patch( + "ansible_collections.community.general.plugins.modules.jenkins_credential.fetch_url" +) +def test_get_jenkins_crumb_fails_on_non_200_status(fetch_mock): + module = MagicMock() + module.params = {"type": "file", "url": "http://localhost:8080"} + headers = {} + + fetch_mock.return_value = (MagicMock(), {"status": 403}) + + jenkins_credential.get_jenkins_crumb(module, headers) + + module.fail_json.assert_called_once() + assert "Failed to fetch Jenkins crumb" in module.fail_json.call_args[1]["msg"] + + +@patch( + "ansible_collections.community.general.plugins.modules.jenkins_credential.fetch_url" +) +def test_get_jenkins_crumb_removes_job_from_url(fetch_mock): + module = MagicMock() + module.params = {"type": "file", "url": "http://localhost:8080/job/test"} + headers = {} + + fake_response = MagicMock() + fake_response.read.return_value = json.dumps( + {"crumbRequestField": "Jenkins-Crumb", "crumb": "xyz"} + ).encode("utf-8") + + fetch_mock.return_value = (fake_response, {"status": 200, "set-cookie": ""}) + + jenkins_credential.get_jenkins_crumb(module, headers) + + url_called = fetch_mock.call_args[0][1] + assert url_called == "http://localhost:8080/crumbIssuer/api/json" + + +def test_clean_data_removes_extraneous_fields(): + data = { + "id": "cred1", + "description": "test", + "jenkins_user": "admin", + "token": "secret", + "url": "http://localhost:8080", + "file_path": None, + } + expected = {"id": "cred1", "description": "test"} + result = jenkins_credential.clean_data(data) + assert result == expected, "Expected {}, got {}".format(expected, result) + + +@patch( + "ansible_collections.community.general.plugins.modules.jenkins_credential.fetch_url" +) +def test_target_exists_returns_true_on_200(fetch_url_mock): + module = MagicMock() + module.params = { + "url": "http://localhost:8080", + "location": "system", + "scope": "_", + "id": "my-id", + "jenkins_user": "admin", + "token": "secret", + "type": "file", + } + + fetch_url_mock.return_value = (MagicMock(), {"status": 200}) + assert jenkins_credential.target_exists(module) is True + + +@patch( + "ansible_collections.community.general.plugins.modules.jenkins_credential.fetch_url" +) +def test_target_exists_returns_false_on_404(fetch_url_mock): + module = MagicMock() + module.params = { + "url": "http://localhost:8080", + "location": "system", + "scope": "_", + "id": "my-id", + "jenkins_user": "admin", + "token": "secret", + "type": "file", + } + + fetch_url_mock.return_value = (MagicMock(), {"status": 404}) + assert jenkins_credential.target_exists(module) is False + + +@patch( + "ansible_collections.community.general.plugins.modules.jenkins_credential.fetch_url" +) +def test_target_exists_calls_fail_json_on_unexpected_status(fetch_url_mock): + module = MagicMock() + module.params = { + "url": "http://localhost:8080", + "location": "system", + "scope": "_", + "id": "my-id", + "jenkins_user": "admin", + "token": "secret", + "type": "file", + } + + fetch_url_mock.return_value = (MagicMock(), {"status": 500}) + jenkins_credential.target_exists(module) + module.fail_json.assert_called_once() + assert "Unexpected status code" in module.fail_json.call_args[1]["msg"] + + +@patch( + "ansible_collections.community.general.plugins.modules.jenkins_credential.fetch_url" +) +def test_target_exists_skips_check_for_token_type(fetch_url_mock): + module = MagicMock() + module.params = { + "type": "token", + "url": "ignored", + "location": "ignored", + "scope": "ignored", + "id": "ignored", + "jenkins_user": "ignored", + "token": "ignored", + } + + assert jenkins_credential.target_exists(module) is False + fetch_url_mock.assert_not_called() + + +@patch( + "ansible_collections.community.general.plugins.modules.jenkins_credential.fetch_url" +) +def test_delete_target_fails_deleting(fetch_mock): + module = MagicMock() + module.params = { + "type": "token", + "jenkins_user": "admin", + "url": "http://localhost:8080", + "id": "token-id", + "location": "system", + "scope": "_", + } + headers = {"Authorization": "Basic abc", "Content-Type": "whatever"} + + fetch_mock.return_value = (MagicMock(), {"status": 500}) + + jenkins_credential.delete_target(module, headers) + + module.fail_json.assert_called_once() + assert "Failed to delete" in module.fail_json.call_args[1]["msg"] + + +@patch( + "ansible_collections.community.general.plugins.modules.jenkins_credential.fetch_url", + side_effect=Exception("network error"), +) +def test_delete_target_raises_exception(fetch_mock): + module = MagicMock() + module.params = { + "type": "scope", + "jenkins_user": "admin", + "location": "system", + "url": "http://localhost:8080", + "id": "domain-id", + "scope": "_", + } + headers = {"Authorization": "Basic auth"} + + jenkins_credential.delete_target(module, headers) + + module.fail_json.assert_called_once() + assert "Exception during delete" in module.fail_json.call_args[1]["msg"] + assert "network error" in module.fail_json.call_args[1]["msg"] + + +def test_read_privateKey_returns_trimmed_contents(): + module = MagicMock() + module.params = {"private_key_path": "/fake/path/key.pem"} + + mocked_file = mock_open( + read_data="\n \t -----BEGIN PRIVATE KEY-----\nKEYDATA\n-----END PRIVATE KEY----- \n\n" + ) + with patch(open_path, mocked_file): + result = jenkins_credential.read_privateKey(module) + + expected = "-----BEGIN PRIVATE KEY-----\nKEYDATA\n-----END PRIVATE KEY-----" + + assert result == expected + mocked_file.assert_called_once_with("/fake/path/key.pem", "r") + + +def test_read_privateKey_handles_file_read_error(): + module = MagicMock() + module.params = {"private_key_path": "/invalid/path.pem"} + + with patch(open_path, side_effect=IOError("cannot read file")): + jenkins_credential.read_privateKey(module) + + module.fail_json.assert_called_once() + assert "Failed to read private key file" in module.fail_json.call_args[1]["msg"] + + +def test_embed_file_into_body_returns_multipart_fields(): + module = MagicMock() + file_path = "/fake/path/secret.pem" + credentials = {"id": "my-id"} + fake_file_content = b"MY SECRET DATA" + + mock = mock_open() + mock.return_value.read.return_value = fake_file_content + + with patch("os.path.basename", return_value="secret.pem"), patch.object( + builtins, "open", mock + ): + body, content_type = jenkins_credential.embed_file_into_body( + module, file_path, credentials.copy() + ) + + assert "multipart/form-data; boundary=" in content_type + + # Check if file content is embedded in body + assert b"MY SECRET DATA" in body + assert b'filename="secret.pem"' in body + + +def test_embed_file_into_body_fails_when_file_unreadable(): + module = MagicMock() + file_path = "/fake/path/missing.pem" + credentials = {"id": "something"} + + with patch(open_path, side_effect=IOError("can't read file")): + jenkins_credential.embed_file_into_body(module, file_path, credentials) + + module.fail_json.assert_called_once() + assert "Failed to read file" in module.fail_json.call_args[1]["msg"] + + +def test_embed_file_into_body_injects_file_keys_into_credentials(): + module = MagicMock() + file_path = "/fake/path/file.txt" + credentials = {"id": "test"} + + with patch(open_path, mock_open(read_data=b"1234")), patch( + "os.path.basename", return_value="file.txt" + ): + + jenkins_credential.embed_file_into_body(module, file_path, credentials) + + assert credentials["file"] == "file0" + assert credentials["fileName"] == "file.txt"