diff --git a/lib/ansible/modules/source_control/github_deploy_key.py b/lib/ansible/modules/source_control/github_deploy_key.py new file mode 100644 index 0000000000..f69581a244 --- /dev/null +++ b/lib/ansible/modules/source_control/github_deploy_key.py @@ -0,0 +1,327 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +ANSIBLE_METADATA = { + 'metadata_version': '1.0', + 'supported_by': 'community', + 'status': ['preview'] +} + +DOCUMENTATION = ''' +--- +module: github_deploy_key +version_added: "2.4" +author: "Ali (@bincyber)" +short_description: Manages deploy keys for GitHub repositories. +description: + - "Adds or removes deploy keys for GitHub repositories. Supports authentication using username and password, + username and password and 2-factor authentication code (OTP), OAuth2 token, or personal access token." +options: + owner: + description: + - The name of the individual account or organization that owns the GitHub repository. + required: true + default: null + aliases: [ 'account', 'organization' ] + repo: + description: + - The name of the GitHub repository. + required: true + default: null + aliases: [ 'repository' ] + name: + description: + - The name for the deploy key. + required: true + default: null + aliases: [ 'title', 'label' ] + key: + description: + - The SSH public key to add to the repository as a deploy key. + required: true + default: null + read_only: + description: + - If C(true), the deploy key will only be able to read repository contents. Otherwise, the deploy key will be able to read and write. + required: false + type: bool + default: yes + state: + description: + - The state of the deploy key. + required: false + default: "present" + choices: [ "present", "absent" ] + force: + description: + - If C(true), forcefully adds the deploy key by deleting any existing deploy key with the same public key or title. + required: false + default: no + type: bool + username: + description: + - The username to authenticate with. + required: false + default: null + password: + description: + - The password to authenticate with. A personal access token can be used here in place of a password. + required: false + default: null + token: + description: + - The OAuth2 token or personal access token to authenticate with. Mutually exclusive with I(password). + required: false + default: null + otp: + description: + - The 6 digit One Time Password for 2-Factor Authentication. Required together with I(username) and I(password). + required: false + default: null +requirements: + - python-requests +notes: + - "Refer to GitHub's API documentation here: https://developer.github.com/v3/repos/keys/." +''' + +EXAMPLES = ''' +# add a new read-only deploy key to a GitHub repository using basic authentication +- github_deploy_key: + owner: "johndoe" + repo: "example" + name: "new-deploy-key" + key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDAwXxn7kIMNWzcDfou..." + read_only: yes + username: "johndoe" + password: "supersecretpassword" + +# remove an existing deploy key from a GitHub repository +- github_deploy_key: + owner: "johndoe" + repository: "example" + name: "new-deploy-key" + key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDAwXxn7kIMNWzcDfou..." + force: yes + username: "johndoe" + password: "supersecretpassword" + state: absent + +# add a new deploy key to a GitHub repository, replace an existing key, use an OAuth2 token to authenticate +- github_deploy_key: + owner: "johndoe" + repository: "example" + name: "new-deploy-key" + key: "{{ lookup('file', '~/.ssh/github.pub') }}" + force: yes + token: "ABAQDAwXxn7kIMNWzcDfo..." + +# re-add a deploy key to a GitHub repository but with a different name +- github_deploy_key: + owner: "johndoe" + repository: "example" + name: "replace-deploy-key" + key: "{{ lookup('file', '~/.ssh/github.pub') }}" + username: "johndoe" + password: "supersecretpassword" + +# add a new deploy key to a GitHub repository using 2FA +- github_deploy_key: + owner: "johndoe" + repo: "example" + name: "new-deploy-key-2" + key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDAwXxn7kIMNWzcDfou..." + username: "johndoe" + password: "supersecretpassword" + otp: 123456 +''' + +RETURN = ''' +msg: + description: the status message describing what occurred + returned: always + type: string + sample: "Deploy key added successfully" + +http_status_code: + description: the HTTP status code returned by the GitHub API + returned: failed + type: int + sample: 400 + +error: + description: the error message returned by the GitHub API + returned: failed + type: string + sample: "key is already in use" + +id: + description: the key identifier assigned by GitHub for the deploy key + returned: changed + type: int + sample: 24381901 +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.urls import fetch_url +import json + + +class GithubDeployKey(object): + def __init__(self, module=None, url=None, state=None, username=None, password=None, token=None, otp=None): + self.module = module + self.url = url + self.state = state + self.username = username + self.password = password + self.token = token + self.otp = otp + self.timeout = 5 + self.auth = None + self.headers = None + + if username is not None and password is not None: + self.module.params['url_username'] = module.params['username'] + self.module.params['url_password'] = module.params['password'] + self.module.params['force_basic_auth'] = True + if self.otp is not None: + self.headers = {"X-GitHub-OTP": self.otp} + else: + self.headers = {"Authorization": "token {}".format(self.token)} + + def get_existing_key(self, key, title, force): + resp, info = fetch_url(self.module, self.url, headers=self.headers, method="GET") + + status_code = info["status"] + + if status_code == 200: + response_body = json.loads(resp.read()) + + if response_body: + for i in response_body: + existing_key_id = str(i["id"]) + if i["key"].split() == key.split()[:2]: + return existing_key_id + elif i['title'] == title and force: + return existing_key_id + else: + if self.state == 'absent': + self.module.exit_json(changed=False, msg="Deploy key does not exist") + else: + return None + elif status_code == 401: + self.module.fail_json(msg="Failed to connect to github.com due to invalid credentials", http_status_code=status_code) + elif status_code == 404: + self.module.fail_json(msg="GitHub repository does not exist", http_status_code=status_code) + else: + self.module.fail_json(msg="Failed to retrieve existing deploy keys", http_status_code=status_code) + + def add_new_key(self, request_body): + resp, info = fetch_url(self.module, self.url, data=json.dumps(request_body), headers=self.headers, method="POST") + + status_code = info["status"] + + if status_code == 201: + response_body = json.loads(resp.read()) + key_id = response_body["id"] + self.module.exit_json(changed=True, msg="Deploy key successfully added", id=key_id) + elif status_code == 401: + self.module.fail_json(msg="Failed to connect to github.com due to invalid credentials", http_status_code=status_code) + elif status_code == 404: + self.module.fail_json(msg="GitHub repository does not exist", http_status_code=status_code) + elif status_code == 422: + self.module.exit_json(changed=False, msg="Deploy key already exists") + else: + err = info["body"] + self.module.fail_json(msg="Failed to add deploy key", http_status_code=status_code, error=err) + + def remove_existing_key(self, key_id): + resp, info = fetch_url(self.module, self.url + "/{}".format(key_id), headers=self.headers, method="DELETE") + + status_code = info["status"] + + if status_code == 204: + if self.state == 'absent': + self.module.exit_json(changed=True, msg="Deploy key successfully deleted", id=key_id) + else: + self.module.fail_json(msg="Failed to delete existing deploy key", id=key_id, http_status_code=status_code) + + +def main(): + + module = AnsibleModule( + argument_spec=dict( + owner=dict(required=True, type='str', aliases=['account', 'organization']), + repo=dict(required=True, type='str', aliases=['repository']), + name=dict(required=True, type='str', aliases=['title', 'label']), + key=dict(required=True, type='str'), + read_only=dict(required=False, type='bool', default=True), + state=dict(default='present', choices=['present', 'absent']), + force=dict(required=False, type='bool', default=False), + username=dict(required=False, type='str'), + password=dict(required=False, type='str', no_log=True), + otp=dict(required=False, type='int', aliases=['2fa_token'], no_log=True), + token=dict(required=False, type='str', no_log=True) + ), + mutually_exclusive=[ + ['password', 'token'] + ], + required_together=[ + ['username', 'password'], + ['otp', 'username', 'password'] + ], + required_one_of=[ + ['username', 'token'] + ], + supports_check_mode=True, + ) + + owner = module.params['owner'] + repo = module.params['repo'] + name = module.params['name'] + key = module.params['key'] + state = module.params['state'] + read_only = module.params.get('read_only', True) + force = module.params.get('force', False) + username = module.params.get('username', None) + password = module.params.get('password', None) + token = module.params.get('token', None) + otp = module.params.get('otp', None) + + GITHUB_API_URL = "https://api.github.com/repos/{}/{}/keys".format(owner, repo) + + deploy_key = GithubDeployKey(module, GITHUB_API_URL, state, username, password, token, otp) + + if module.check_mode: + key_id = deploy_key.get_existing_key(key, name, force) + if state == "present" and key_id is None: + module.exit_json(changed=True) + elif state == "present" and key_id is not None: + module.exit_json(changed=False) + + # to forcefully modify an existing key, the existing key must be deleted first + if state == 'absent' or force: + key_id = deploy_key.get_existing_key(key, name, force) + + if key_id is not None: + deploy_key.remove_existing_key(key_id) + + deploy_key.add_new_key({"title": name, "key": key, "read_only": read_only}) + + +if __name__ == '__main__': + main()