oc_csr_approve.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. #!/usr/bin/env python
  2. """oc_csr_approve module"""
  3. # Copyright 2020 Red Hat, Inc. and/or its affiliates
  4. # and other contributors as indicated by the @author tags.
  5. #
  6. # Licensed under the Apache License, Version 2.0 (the "License");
  7. # you may not use this file except in compliance with the License.
  8. # You may obtain a copy of the License at
  9. #
  10. # http://www.apache.org/licenses/LICENSE-2.0
  11. #
  12. # Unless required by applicable law or agreed to in writing, software
  13. # distributed under the License is distributed on an "AS IS" BASIS,
  14. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  15. # See the License for the specific language governing permissions and
  16. # limitations under the License.
  17. import base64
  18. import json
  19. import time
  20. from ansible.module_utils.basic import AnsibleModule
  21. try:
  22. # Python >= 3.5
  23. from json.decoder import JSONDecodeError
  24. except ImportError:
  25. # Python < 3.5
  26. JSONDecodeError = ValueError
  27. DOCUMENTATION = '''
  28. ---
  29. module: oc_csr_approve
  30. short_description: Retrieve and approve node client and server CSRs
  31. version_added: "2.9"
  32. description:
  33. - Retrieve and approve node client and server CSRs
  34. author:
  35. - "Michael Gugino <mgugino@redhat.com>"
  36. - "Russell Teague <rteague@redhat.com>"
  37. '''
  38. EXAMPLES = '''
  39. - name: Approve node CSRs
  40. oc_csr_approve:
  41. kubeconfig: "{{ openshift_node_kubeconfig_path }}"
  42. nodename: "{{ ansible_nodename | lower }}"
  43. delegate_to: localhost
  44. '''
  45. CERT_MODE = {'client': 'client auth', 'server': 'server auth'}
  46. def parse_subject_cn(subject_str):
  47. """parse output of openssl req -noout -subject to retrieve CN.
  48. example input:
  49. 'subject=/C=US/CN=test.io/L=Raleigh/O=Red Hat/ST=North Carolina/OU=OpenShift\n'
  50. or
  51. 'subject=C = US, CN = test.io, L = City, O = Company, ST = State, OU = Dept\n'
  52. example output: 'test.io'
  53. """
  54. stripped_string = subject_str[len('subject='):].strip()
  55. kv_strings = [x.strip() for x in stripped_string.split(',')]
  56. if len(kv_strings) == 1:
  57. kv_strings = [x.strip() for x in stripped_string.split('/')][1:]
  58. for item in kv_strings:
  59. item_parts = [x.strip() for x in item.split('=')]
  60. if item_parts[0] == 'CN':
  61. return item_parts[1]
  62. return None
  63. def csr_present_check(nodename, csr_dict):
  64. """Ensure node has a CSR
  65. Returns True if CSR for node is present"""
  66. for _, val in csr_dict.items():
  67. if val == nodename:
  68. # CSR for node is present
  69. return True
  70. # Didn't find a CSR for node
  71. return False
  72. class CSRapprove(object): # pylint: disable=useless-object-inheritance
  73. """Approves node CSRs"""
  74. def __init__(self, module, oc_bin, kubeconfig, nodename):
  75. """init method"""
  76. self.module = module
  77. self.oc_bin = oc_bin
  78. self.kubeconfig = kubeconfig
  79. self.nodename = nodename
  80. # Build a dictionary to hold all of our output information so nothing
  81. # is lost when we fail.
  82. self.result = {'changed': False,
  83. 'rc': 0,
  84. 'client_approve_results': [],
  85. 'server_approve_results': [],
  86. }
  87. def run_command(self, command, rc_opts=None):
  88. """Run a command using AnsibleModule.run_command, or fail"""
  89. if rc_opts is None:
  90. rc_opts = {}
  91. rtnc, stdout, err = self.module.run_command(command, **rc_opts)
  92. if rtnc:
  93. self.result['failed'] = True
  94. self.result['rc'] = rtnc
  95. self.result['msg'] = str(err)
  96. self.result['state'] = 'unknown'
  97. self.module.fail_json(**self.result)
  98. return stdout
  99. def get_nodes(self):
  100. """Get all nodes via oc get nodes -ojson"""
  101. # json output is necessary for consistency here.
  102. command = "{} {} get nodes -ojson".format(self.oc_bin, self.kubeconfig)
  103. stdout = self.run_command(command)
  104. try:
  105. data = json.loads(stdout)
  106. except JSONDecodeError as err:
  107. self.result['failed'] = True
  108. self.result['rc'] = 1
  109. self.result['msg'] = str(err)
  110. self.result['state'] = 'unknown'
  111. self.module.fail_json(**self.result)
  112. return [node['metadata']['name'] for node in data['items']]
  113. def get_csrs(self):
  114. """Retrieve CSRs from cluster using oc get csr -ojson"""
  115. command = "{} {} get csr -ojson".format(self.oc_bin, self.kubeconfig)
  116. stdout = self.run_command(command)
  117. try:
  118. data = json.loads(stdout)
  119. except JSONDecodeError as err:
  120. self.result['failed'] = True
  121. self.result['rc'] = 1
  122. self.result['msg'] = str(err)
  123. self.result['state'] = 'unknown'
  124. self.module.fail_json(**self.result)
  125. return data['items']
  126. def process_csrs(self, csrs, mode):
  127. """Return a dictionary of pending CSRs where the format of the dict is
  128. k=csr name, v=Subject Common Name"""
  129. csr_dict = {}
  130. for item in csrs:
  131. status = item['status'].get('conditions')
  132. if status:
  133. # If status is not an empty dictionary, cert is not pending.
  134. continue
  135. if CERT_MODE[mode] not in item['spec']['usages']:
  136. continue
  137. name = item['metadata']['name']
  138. request_data = base64.b64decode(item['spec']['request'])
  139. command = "openssl req -noout -subject"
  140. # ansible's module.run_command accepts data to pipe via stdin as
  141. # as 'data' kwarg.
  142. rc_opts = {'data': request_data, 'binary_data': True}
  143. stdout = self.run_command(command, rc_opts=rc_opts)
  144. # parse common_name from subject string.
  145. common_name = parse_subject_cn(stdout)
  146. if common_name and common_name.startswith('system:node:'):
  147. # common name is typically prepended with system:node:.
  148. common_name = common_name.split('system:node:')[1]
  149. # we only want to approve CSRs from nodes we know about.
  150. if common_name == self.nodename:
  151. csr_dict[name] = common_name
  152. return csr_dict
  153. def approve_csrs(self, csr_pending_list, mode):
  154. """Loop through csr_pending_list and call:
  155. oc adm certificate approve <item>"""
  156. results_mode = "{}_approve_results".format(mode)
  157. base_command = "{} {} adm certificate approve {}"
  158. approve_results = []
  159. for csr in csr_pending_list:
  160. command = base_command.format(self.oc_bin, self.kubeconfig, csr)
  161. rtnc, stdout, err = self.module.run_command(command)
  162. if rtnc:
  163. self.result['failed'] = True
  164. self.result['rc'] = rtnc
  165. self.result['msg'] = str(err)
  166. self.result[results_mode].extend(approve_results)
  167. self.result['state'] = 'unknown'
  168. self.module.fail_json(**self.result)
  169. approve_results.append("{}: {}".format(csr_pending_list[csr], stdout))
  170. self.result[results_mode].extend(approve_results)
  171. # We set changed for approved client or server CSRs.
  172. self.result['changed'] = bool(approve_results) or bool(self.result['changed'])
  173. def node_is_ready(self, nodename):
  174. """Determine if node has working server certificate
  175. Returns True if the node is ready"""
  176. base_command = "{} {} get --raw /api/v1/nodes/{}/proxy/healthz"
  177. # need this to look like /api/v1/nodes/<node>/proxy/healthz
  178. # if we can hit that api endpoint (rtnc=0), the node has a valid server cert.
  179. command = base_command.format(self.oc_bin, self.kubeconfig, nodename)
  180. rtnc, _, _ = self.module.run_command(command)
  181. return not bool(rtnc)
  182. def runner(self, attempts, mode):
  183. """Approve CSRs if they are present for node"""
  184. results_mode = "{}_approve_results".format(mode)
  185. # Get all CSRs, no good way to filter on pending.
  186. csrs = self.get_csrs()
  187. # process data in CSRs and build a dictionary of requests
  188. csr_dict = self.process_csrs(csrs, mode)
  189. if csr_present_check(self.nodename, csr_dict):
  190. # Approve outstanding CSRs for node
  191. self.approve_csrs(csr_dict, mode)
  192. else:
  193. # CSR is not present, increment attempts and retry
  194. if attempts < 36: # 36 * 5 = 3 minutes waiting for CSRs
  195. self.result[results_mode].append(
  196. "Attempt: {}, Node {} not present or CSR not yet available".format(attempts, self.nodename))
  197. attempts += 1
  198. time.sleep(5)
  199. else:
  200. # If attempts < 36, fail waiting for CSRs to appear
  201. # Using 'describe' to have the API provide the decoded results for all CSRs
  202. command = "{} {} describe csr".format(self.oc_bin, self.kubeconfig)
  203. stdout = self.run_command(command)
  204. self.result['failed'] = True
  205. self.result['rc'] = 1
  206. self.result['msg'] = "Node {} not present or could not find {} CSR".format(self.nodename, mode)
  207. self.result['oc_describe_csr'] = stdout
  208. self.module.fail_json(**self.result)
  209. return attempts
  210. def run(self):
  211. """execute the CSR approval process"""
  212. # # Client Cert Section # #
  213. mode = "client"
  214. attempts = 1
  215. while True:
  216. # If the node is in the list of all nodes, we do not need to approve client CSRs
  217. if self.nodename not in self.get_nodes():
  218. attempts = self.runner(attempts, mode)
  219. else:
  220. self.result["{}_approve_results".format(mode)].append(
  221. "Node {} is present in node list".format(self.nodename))
  222. break
  223. # # Server Cert Section # #
  224. mode = "server"
  225. attempts = 1
  226. while True:
  227. # If the node API is healthy, we do not need to approve server CSRs
  228. if not self.node_is_ready(self.nodename):
  229. attempts = self.runner(attempts, mode)
  230. else:
  231. self.result["{}_approve_results".format(mode)].append(
  232. "Node {} API is ready".format(self.nodename))
  233. break
  234. self.module.exit_json(**self.result)
  235. def run_module():
  236. """Run this module"""
  237. module_args = dict(
  238. oc_bin=dict(type='path', required=False, default='oc'),
  239. kubeconfig=dict(type='path', required=True),
  240. nodename=dict(type='str', required=True),
  241. )
  242. module = AnsibleModule(
  243. supports_check_mode=False,
  244. argument_spec=module_args
  245. )
  246. oc_bin = module.params['oc_bin']
  247. kubeconfig = '--kubeconfig={}'.format(module.params['kubeconfig'])
  248. nodename = module.params['nodename']
  249. approver = CSRapprove(module, oc_bin, kubeconfig, nodename)
  250. approver.run()
  251. def main():
  252. """main"""
  253. run_module()
  254. if __name__ == '__main__':
  255. main()