summaryrefslogtreecommitdiffstats
path: root/ansible_collections/community/general/plugins/modules/github_deploy_key.py
blob: ae90e04c9169bfa44f3362b0c7345709d5a121a1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
#!/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 = '''
---
module: github_deploy_key
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. Admin
  rights on the repository are required."
extends_documentation_fragment:
  - community.general.attributes
attributes:
  check_mode:
    support: full
  diff_mode:
    support: none
options:
  github_url:
    description:
      - The base URL of the GitHub API
    required: false
    type: str
    version_added: '0.2.0'
    default: https://api.github.com
  owner:
    description:
      - The name of the individual account or organization that owns the GitHub repository.
    required: true
    aliases: [ 'account', 'organization' ]
    type: str
  repo:
    description:
      - The name of the GitHub repository.
    required: true
    aliases: [ 'repository' ]
    type: str
  name:
    description:
      - The name for the deploy key.
    required: true
    aliases: [ 'title', 'label' ]
    type: str
  key:
    description:
      - The SSH public key to add to the repository as a deploy key.
    required: true
    type: str
  read_only:
    description:
      - If V(true), the deploy key will only be able to read repository contents. Otherwise, the deploy key will be able to read and write.
    type: bool
    default: true
  state:
    description:
      - The state of the deploy key.
    default: "present"
    choices: [ "present", "absent" ]
    type: str
  force:
    description:
      - If V(true), forcefully adds the deploy key by deleting any existing deploy key with the same public key or title.
    type: bool
    default: false
  username:
    description:
      - The username to authenticate with. Should not be set when using personal access token
    type: str
  password:
    description:
      - The password to authenticate with. Alternatively, a personal access token can be used instead of O(username) and O(password) combination.
    type: str
  token:
    description:
      - The OAuth2 token or personal access token to authenticate with. Mutually exclusive with O(password).
    type: str
  otp:
    description:
      - The 6 digit One Time Password for 2-Factor Authentication. Required together with O(username) and O(password).
    type: int
notes:
   - "Refer to GitHub's API documentation here: https://developer.github.com/v3/repos/keys/."
'''

EXAMPLES = '''
- name: Add a new read-only deploy key to a GitHub repository using basic authentication
  community.general.github_deploy_key:
    owner: "johndoe"
    repo: "example"
    name: "new-deploy-key"
    key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDAwXxn7kIMNWzcDfou..."
    read_only: true
    username: "johndoe"
    password: "supersecretpassword"

- name: Remove an existing deploy key from a GitHub repository
  community.general.github_deploy_key:
    owner: "johndoe"
    repository: "example"
    name: "new-deploy-key"
    key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDAwXxn7kIMNWzcDfou..."
    force: true
    username: "johndoe"
    password: "supersecretpassword"
    state: absent

- name: Add a new deploy key to a GitHub repository, replace an existing key, use an OAuth2 token to authenticate
  community.general.github_deploy_key:
    owner: "johndoe"
    repository: "example"
    name: "new-deploy-key"
    key: "{{ lookup('file', '~/.ssh/github.pub') }}"
    force: true
    token: "ABAQDAwXxn7kIMNWzcDfo..."

- name: Re-add a deploy key to a GitHub repository but with a different name
  community.general.github_deploy_key:
    owner: "johndoe"
    repository: "example"
    name: "replace-deploy-key"
    key: "{{ lookup('file', '~/.ssh/github.pub') }}"
    username: "johndoe"
    password: "supersecretpassword"

- name: Add a new deploy key to a GitHub repository using 2FA
  community.general.github_deploy_key:
    owner: "johndoe"
    repo: "example"
    name: "new-deploy-key-2"
    key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDAwXxn7kIMNWzcDfou..."
    username: "johndoe"
    password: "supersecretpassword"
    otp: 123456

- name: Add a read-only deploy key to a repository hosted on GitHub Enterprise
  community.general.github_deploy_key:
    github_url: "https://api.example.com"
    owner: "janedoe"
    repo: "example"
    name: "new-deploy-key"
    key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDAwXxn7kIMNWzcDfou..."
    read_only: true
    username: "janedoe"
    password: "supersecretpassword"
'''

RETURN = '''
msg:
    description: the status message describing what occurred
    returned: always
    type: str
    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: str
    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
from re import findall


class GithubDeployKey(object):
    def __init__(self, module):
        self.module = module

        self.github_url = self.module.params['github_url']
        self.name = module.params['name']
        self.key = module.params['key']
        self.state = module.params['state']
        self.read_only = module.params.get('read_only', True)
        self.force = module.params.get('force', False)
        self.username = module.params.get('username', None)
        self.password = module.params.get('password', None)
        self.token = module.params.get('token', None)
        self.otp = module.params.get('otp', None)

    @property
    def url(self):
        owner = self.module.params['owner']
        repo = self.module.params['repo']
        return "{0}/repos/{1}/{2}/keys".format(self.github_url, owner, repo)

    @property
    def headers(self):
        if self.username is not None and self.password is not None:
            self.module.params['url_username'] = self.username
            self.module.params['url_password'] = self.password
            self.module.params['force_basic_auth'] = True
            if self.otp is not None:
                return {"X-GitHub-OTP": self.otp}
        elif self.token is not None:
            return {"Authorization": "token {0}".format(self.token)}
        else:
            return None

    def paginate(self, url):
        while url:
            resp, info = fetch_url(self.module, url, headers=self.headers, method="GET")

            if info["status"] == 200:
                yield self.module.from_json(resp.read())

                links = {}
                for x, y in findall(r'<([^>]+)>;\s*rel="(\w+)"', info.get("link", '')):
                    links[y] = x

                url = links.get('next')
            else:
                self.handle_error(method="GET", info=info)

    def get_existing_key(self):
        for keys in self.paginate(self.url):
            if keys:
                for i in keys:
                    existing_key_id = str(i["id"])
                    if i["key"].split() == self.key.split()[:2]:
                        return existing_key_id
                    elif i['title'] == self.name and self.force:
                        return existing_key_id
            else:
                return None

    def add_new_key(self):
        request_body = {"title": self.name, "key": self.key, "read_only": self.read_only}

        resp, info = fetch_url(self.module, self.url, data=self.module.jsonify(request_body), headers=self.headers, method="POST", timeout=30)

        status_code = info["status"]

        if status_code == 201:
            response_body = self.module.from_json(resp.read())
            key_id = response_body["id"]
            self.module.exit_json(changed=True, msg="Deploy key successfully added", id=key_id)
        elif status_code == 422:
            self.module.exit_json(changed=False, msg="Deploy key already exists")
        else:
            self.handle_error(method="POST", info=info)

    def remove_existing_key(self, key_id):
        resp, info = fetch_url(self.module, "{0}/{1}".format(self.url, 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.handle_error(method="DELETE", info=info, key_id=key_id)

    def handle_error(self, method, info, key_id=None):
        status_code = info['status']
        body = info.get('body')
        if body:
            err = self.module.from_json(body)['message']

        if status_code == 401:
            self.module.fail_json(msg="Failed to connect to {0} due to invalid credentials".format(self.github_url), http_status_code=status_code, error=err)
        elif status_code == 404:
            self.module.fail_json(msg="GitHub repository does not exist", http_status_code=status_code, error=err)
        else:
            if method == "GET":
                self.module.fail_json(msg="Failed to retrieve existing deploy keys", http_status_code=status_code, error=err)
            elif method == "POST":
                self.module.fail_json(msg="Failed to add deploy key", http_status_code=status_code, error=err)
            elif method == "DELETE":
                self.module.fail_json(msg="Failed to delete existing deploy key", id=key_id, http_status_code=status_code, error=err)


def main():
    module = AnsibleModule(
        argument_spec=dict(
            github_url=dict(required=False, type='str', default="https://api.github.com"),
            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', no_log=False),
            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', 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,
    )

    deploy_key = GithubDeployKey(module)

    if module.check_mode:
        key_id = deploy_key.get_existing_key()
        if deploy_key.state == "present" and key_id is None:
            module.exit_json(changed=True)
        elif deploy_key.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 deploy_key.state == 'absent' or deploy_key.force:
        key_id = deploy_key.get_existing_key()

        if key_id is not None:
            deploy_key.remove_existing_key(key_id)
        elif deploy_key.state == 'absent':
            module.exit_json(changed=False, msg="Deploy key does not exist")

    if deploy_key.state == "present":
        deploy_key.add_new_key()


if __name__ == '__main__':
    main()