Coverage for plugins/modules/purefb_certs.py: 95%

162 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-10-30 17:06 -0400

1#!/usr/bin/python 

2# -*- coding: utf-8 -*- 

3 

4# (c) 2024, Simon Dodsley (simon@everpuredata.com) 

5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 

6 

7from __future__ import absolute_import, division, print_function 

8 

9__metaclass__ = type 

10 

11ANSIBLE_METADATA = { 

12 "metadata_version": "1.1", 

13 "status": ["preview"], 

14 "supported_by": "community", 

15} 

16 

17DOCUMENTATION = r""" 

18--- 

19module: purefb_certs 

20version_added: '1.4.0' 

21short_description: Manage FlashBlade SSL Certificates 

22description: 

23- Create, delete, import and export FlashBlade SSL Certificates 

24author: 

25- Everpure Ansible Team (@sdodsley) <pure-ansible-team@everpuredata.com> 

26options: 

27 name: 

28 description: 

29 - Name of the SSL Certificate 

30 type: str 

31 required: true 

32 state: 

33 description: 

34 - Action for the module to perform 

35 - I(present) will create or import an SSL certificate including self signed certificates 

36 - I(absent) will delete an existing SSL certificate 

37 - I(sign) will construct a Certificate Signing request (CSR) from an existing (self signed) certificate 

38 - I(export) will export the existing SSL certificate 

39 - I(import) will import or create a provided certificate. 

40 default: present 

41 choices: [ absent, present, import, export, sign ] 

42 type: str 

43 certificate_type: 

44 description: 

45 - Type can be I(array) for FlashBlade as server or I(external) for FlashBlade as client (eg access to AD). 

46 type: str 

47 choices: [ external, array ] 

48 certificate: 

49 aliases: [ contents ] 

50 type: str 

51 description: 

52 - Required for I(import) 

53 - A valid signed certicate in PEM format (Base64 encoded) 

54 - Includes the "-----BEGIN CERTIFICATE-----" and "-----END CERTIFICATE-----" lines 

55 - Does not exceed 3000 characters in length 

56 intermediate_cert: 

57 aliases: [ intermeadiate_cert ] 

58 type: str 

59 description: 

60 - Intermeadiate certificate provided by the CA 

61 key: 

62 aliases: [ private_key ] 

63 type: str 

64 description: 

65 - Certificates of type I(array) must have a private key 

66 - If the Certificate Signed Request (CSR) was not constructed on the system 

67 or the private key has changed since construction of the CSR, provide 

68 a new private key here 

69 passphrase: 

70 type: str 

71 description: 

72 - Passphrase if the private key is encrypted 

73 export_file: 

74 type: str 

75 description: 

76 - Name of file to contain Certificate Signing Request when `status sign` 

77 - Name of file to export the current SSL Certificate when `status export` 

78 - File will be overwritten if it already exists 

79 country: 

80 type: str 

81 description: 

82 - The two-letter ISO code for the country where your organization is located 

83 province: 

84 type: str 

85 description: 

86 - The full name of the state or province where your organization is located 

87 locality: 

88 type: str 

89 description: 

90 - The full name of the city where your organization is located 

91 organization: 

92 type: str 

93 description: 

94 - The full and exact legal name of your organization. 

95 - The organization name should not be abbreviated and should 

96 include suffixes such as Inc, Corp, or LLC. 

97 org_unit: 

98 type: str 

99 description: 

100 - The department within your organization that is managing the certificate 

101 common_name: 

102 type: str 

103 description: 

104 - The fully qualified domain name (FQDN) of the current system 

105 - For example, the common name for https://pureblade.example.com is 

106 pureblade.example.com, or *.example.com for a wildcard certificate 

107 - This can also be the management IP address of the system or the 

108 shortname of the current system. 

109 - Maximum of 64 characters 

110 - If not provided this will default to the shortname of the system 

111 email: 

112 type: str 

113 description: 

114 - The email address used to contact your organization 

115 key_size: 

116 type: int 

117 description: 

118 - The key size in bits if you generate a new private key 

119 choices: [ 1024, 2048, 4096 ] 

120 key_algorithm: 

121 type: str 

122 description: 

123 - The key algorithm used to generate the certificate. 

124 choices: [ rsa, ec, ed448, ed25519 ] 

125 days: 

126 type: int 

127 description: 

128 - The number of valid days for the self-signed certificate being generated 

129 - If not specified, the self-signed certificate expires after 3650 days. 

130 generate: 

131 default: false 

132 type: bool 

133 description: 

134 - Generate a new private key. 

135 - If not selected, the certificate will use the existing key 

136 - Required when changing key-size or key-algorithm 

137 - On an existing certificate I(state=present) only re-issues the 

138 certificate when a requested attribute differs from the one on the 

139 array, or when this option is C(true); otherwise nothing changes. 

140 subject_alternative_names: 

141 type: list 

142 elements: str 

143 description: 

144 - The alternative names that are secured by this certificate. 

145 - Alternative names may be IP addresses, DNS names, or URIs. 

146 version_added: "1.22.0" 

147extends_documentation_fragment: 

148- everpure.flashblade.everpure.fb 

149""" 

150 

151EXAMPLES = r""" 

152- name: Create self-signed SSL certifcate foo 

153 everpure.flashblade.purefb_certs: 

154 name: foo 

155 key_size: 4096 

156 country: US 

157 province: FL 

158 locality: Miami 

159 organization: "Acme Inc" 

160 org_unit: "DevOps" 

161 fb_url: 10.10.10.2 

162 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641 

163 

164- name: Delete SSL certificate foo 

165 everpure.flashblade.purefb_certs: 

166 name: foo 

167 state: absent 

168 fb_url: 10.10.10.2 

169 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641 

170 

171- name: Request CSR 

172 everpure.flashblade.purefb_certs: 

173 name: foo 

174 state: sign 

175 export_file: <filepath> 

176 fb_url: 10.10.10.2 

177 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641 

178 

179- name: Request CSR with updated fields 

180 everpure.flashblade.purefb_certs: 

181 name: foo 

182 state: sign 

183 export_file: <filepath> 

184 org_unit: Development 

185 fb_url: 10.10.10.2 

186 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641 

187 

188- name: Regenerate key for self-signed SSL foo 

189 everpure.flashblade.purefb_certs: 

190 generate: true 

191 name: foo 

192 fb_url: 10.10.10.2 

193 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641 

194 

195- name: Import SSL Cert foo and Private Key 

196 everpure.flashblade.purefb_certs: 

197 state: import 

198 name: foo 

199 certificate: "{{lookup('file', 'example.crt') }}" 

200 key: "{{lookup('file', 'example.key') }}" 

201 fb_url: 10.10.10.2 

202 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641 

203""" 

204 

205RETURN = r""" 

206""" 

207 

208HAS_PYPURECLIENT = True 

209try: 

210 from pypureclient.flashblade import ( 

211 CertificatePost, 

212 CertificateSigningRequestPost, 

213 Reference, 

214 CertificatePatch, 

215 ) 

216except ImportError: 

217 HAS_PYPURECLIENT = False 

218 

219HAS_PYCOUNTRY = True 

220try: 

221 import pycountry 

222except ImportError: 

223 HAS_PYCOUNTRY = False 

224 

225import re 

226from ansible.module_utils.basic import AnsibleModule 

227from ansible_collections.everpure.flashblade.plugins.module_utils.purefb import ( 

228 get_system, 

229 purefb_argument_spec, 

230) 

231from ansible_collections.everpure.flashblade.plugins.module_utils.version import ( 

232 LooseVersion, 

233) 

234from ansible_collections.everpure.flashblade.plugins.module_utils.common import ( 

235 get_error_message, 

236 get_rest_api_version, 

237) 

238 

239CERT_TYPE_VERSION = "2.15" 

240CSR_API_VERSION = "2.20" 

241 

242 

243# module option -> attribute of the Certificate object returned by the array 

244SUBJECT_FIELDS = { 

245 "common_name": "common_name", 

246 "country": "country", 

247 "email": "email", 

248 "locality": "locality", 

249 "organization": "organization", 

250 "org_unit": "organizational_unit", 

251 "province": "state", 

252} 

253KEY_FIELDS = { 

254 "key_size": "key_size", 

255 "key_algorithm": "key_algorithm", 

256} 

257 

258 

259def _text_differs(requested, current): 

260 """Compare PEM text ignoring surrounding whitespace""" 

261 return (requested or "").strip() != (current or "").strip() 

262 

263 

264def get_cert(module, blade): 

265 """Return the existing certificate object""" 

266 res = blade.get_certificates(names=[module.params["name"]]) 

267 if res.status_code != 200: 

268 module.fail_json( 

269 msg="Failed to read SSL certificate {0}. Error: {1}".format( 

270 module.params["name"], get_error_message(res) 

271 ) 

272 ) 

273 return list(res.items)[0] 

274 

275 

276def cert_changes(module, current): 

277 """Return the names of the options whose requested value differs 

278 from the existing certificate. 

279 

280 Options that were not given (None) are never a difference. ``days`` 

281 cannot be read back from the array so it never triggers a change on 

282 its own; it is only sent along with a regeneration. 

283 """ 

284 changes = [] 

285 if module.params["certificate"] is not None and _text_differs( 

286 module.params["certificate"], getattr(current, "certificate", None) 

287 ): 

288 changes.append("certificate") 

289 if module.params["intermediate_cert"] is not None and _text_differs( 

290 module.params["intermediate_cert"], 

291 getattr(current, "intermediate_certificate", None), 

292 ): 

293 changes.append("intermediate_cert") 

294 for option, attribute in SUBJECT_FIELDS.items(): 

295 if module.params[option] is not None and module.params[option] != getattr( 

296 current, attribute, None 

297 ): 

298 changes.append(option) 

299 for option, attribute in KEY_FIELDS.items(): 

300 if module.params[option] is not None and module.params[option] != getattr( 

301 current, attribute, None 

302 ): 

303 changes.append(option) 

304 if module.params["subject_alternative_names"] is not None: 

305 current_sans = getattr(current, "subject_alternative_names", None) or [] 

306 if sorted(module.params["subject_alternative_names"]) != sorted(current_sans): 

307 changes.append("subject_alternative_names") 

308 return changes 

309 

310 

311def update_cert(module, blade): 

312 """Update existing SSL Certificate 

313 

314 Only patch when a requested attribute differs from the certificate on 

315 the array, or when ``generate`` asks for a new private key. The key 

316 parameters (``key_size``, ``key_algorithm``) are only ever sent together 

317 with ``generate_new_key=true`` because the array rejects them otherwise. 

318 """ 

319 api_versions = get_rest_api_version(blade) 

320 current = get_cert(module, blade) 

321 changes = cert_changes(module, current) 

322 generate = bool(module.params["generate"]) 

323 

324 if LooseVersion(CSR_API_VERSION) <= LooseVersion(api_versions): 

325 key_changes = [option for option in changes if option in KEY_FIELDS] 

326 if key_changes and not generate: 

327 module.fail_json( 

328 msg=( 

329 "{0} differ(s) from the existing certificate {1}. " 

330 "Set generate: true to regenerate the private key." 

331 ).format(", ".join(key_changes), module.params["name"]) 

332 ) 

333 changed = bool(changes or generate) 

334 if changed and not module.check_mode: 

335 patch_kwargs = dict( 

336 certificate=module.params["certificate"], 

337 intermediate_certificate=module.params["intermediate_cert"], 

338 private_key=module.params["key"], 

339 passphrase=module.params["passphrase"], 

340 common_name=module.params["common_name"], 

341 country=module.params["country"], 

342 email=module.params["email"], 

343 locality=module.params["locality"], 

344 organization=module.params["organization"], 

345 organizational_unit=module.params["org_unit"], 

346 state=module.params["province"], 

347 days=module.params["days"], 

348 subject_alternative_names=module.params["subject_alternative_names"], 

349 ) 

350 if generate: 

351 patch_kwargs["key_size"] = module.params["key_size"] 

352 patch_kwargs["key_algorithm"] = module.params["key_algorithm"] 

353 res = blade.patch_certificates( 

354 names=[module.params["name"]], 

355 generate_new_key=True if generate else None, 

356 certificate=CertificatePatch(**patch_kwargs), 

357 ) 

358 if res.status_code != 200: 

359 module.fail_json( 

360 msg="Updating existing SSL certificate {0} failed. Error: {1}".format( 

361 module.params["name"], 

362 get_error_message(res), 

363 ) 

364 ) 

365 else: 

366 changed = bool("certificate" in changes or "intermediate_cert" in changes) 

367 if changed and not module.check_mode: 

368 certificate = CertificatePatch( 

369 certificate=module.params["certificate"], 

370 intermediate_certificate=module.params["intermediate_cert"], 

371 private_key=module.params["key"], 

372 passphrase=module.params["passphrase"], 

373 ) 

374 res = blade.patch_certificates( 

375 names=[module.params["name"]], certificate=certificate 

376 ) 

377 if res.status_code != 200: 

378 module.fail_json( 

379 msg="Updating existing SSL certificate {0} failed. Error: {1}".format( 

380 module.params["name"], get_error_message(res) 

381 ) 

382 ) 

383 module.exit_json(changed=changed) 

384 

385 

386def create_cert(module, blade): 

387 # let the rest-api itself deal with errors 

388 changed = True 

389 api_versions = get_rest_api_version(blade) 

390 if LooseVersion(CSR_API_VERSION) <= LooseVersion(api_versions): 

391 certificate = CertificatePost( 

392 certificate_type=module.params["certificate_type"], 

393 certificate=module.params["certificate"], 

394 intermediate_certificate=module.params["intermediate_cert"], 

395 private_key=module.params["key"], 

396 passphrase=module.params["passphrase"], 

397 common_name=module.params["common_name"], 

398 country=module.params["country"], 

399 email=module.params["email"], 

400 key_size=module.params["key_size"], 

401 key_algorithm=module.params["key_algorithm"], 

402 locality=module.params["locality"], 

403 organization=module.params["organization"], 

404 organizational_unit=module.params["org_unit"], 

405 state=module.params["province"], 

406 days=module.params["days"], 

407 subject_alternative_names=module.params["subject_alternative_names"], 

408 ) 

409 else: 

410 if LooseVersion(CERT_TYPE_VERSION) <= LooseVersion(api_versions): 

411 certificate = CertificatePost( 

412 certificate_type=module.params["certificate_type"], 

413 certificate=module.params["certificate"], 

414 intermediate_certificate=module.params["intermediate_cert"], 

415 private_key=module.params["key"], 

416 passphrase=module.params["passphrase"], 

417 ) 

418 else: 

419 certificate = CertificatePost( 

420 certificate=module.params["certificate"], 

421 intermediate_certificate=module.params["intermediate_cert"], 

422 private_key=module.params["key"], 

423 passphrase=module.params["passphrase"], 

424 ) 

425 

426 if not module.check_mode: 

427 res = blade.post_certificates( 

428 names=[module.params["name"]], certificate=certificate 

429 ) 

430 if res.status_code != 200: 

431 module.fail_json( 

432 msg="Creating SSL certificate {0} failed. Error: {1}".format( 

433 module.params["name"], get_error_message(res) 

434 ) 

435 ) 

436 

437 module.exit_json(changed=changed) 

438 

439 

440def delete_cert(module, blade): 

441 changed = True 

442 if module.params["name"] == "global": 

443 module.fail_json(msg="Global certificate cannot be deleted") 

444 if not module.check_mode: 

445 res = blade.delete_certificates(names=[module.params["name"]]) 

446 if res.status_code != 200: 

447 module.fail_json( 

448 msg="Failed to delete {0} SSL certificate. Error: {1}".format( 

449 module.params["name"], get_error_message(res) 

450 ) 

451 ) 

452 module.exit_json(changed=changed) 

453 

454 

455def export_cert(module, blade): 

456 """Export current SSL certificate""" 

457 changed = True 

458 if not module.check_mode: 

459 ssl = blade.get_certificates(names=[module.params["name"]]) 

460 if ssl.status_code != 200: 

461 module.fail_json( 

462 msg="Exporting Certificate failed. Error: {0}".format( 

463 ssl.errors[0].message 

464 ) 

465 ) 

466 with open(module.params["export_file"], "w", encoding="utf-8") as ssl_file: 

467 ssl_file.write(list(ssl.items)[0].certificate) 

468 module.exit_json(changed=changed) 

469 

470 

471def create_csr(module, blade): 

472 """Construct a Certificate Signing Request 

473 

474 Output the result to a specified file 

475 """ 

476 changed = True 

477 if not module.check_mode: 

478 certificate = CertificateSigningRequestPost( 

479 certificate=Reference(name=module.params["name"]), 

480 common_name=module.params["common_name"], 

481 country=module.params["country"], 

482 email=module.params["email"], 

483 locality=module.params["locality"], 

484 organization=module.params["organization"], 

485 organizational_unit=module.params["org_unit"], 

486 state=module.params["province"], 

487 subject_alternative_names=module.params["subject_alternative_names"], 

488 ) 

489 csr = list( 

490 blade.post_certificates_certificate_signing_requests( 

491 certificate=certificate 

492 ).items 

493 )[0].certificate_signing_request 

494 with open(module.params["export_file"], "w", encoding="utf-8") as csr_file: 

495 csr_file.write(csr) 

496 module.exit_json(changed=changed) 

497 

498 

499def main(): 

500 # Do not set defaults, if you do you will have to craft each call to make sure 

501 # you are not trying to write values that are read-only given the other parameters 

502 # Exceptions: state, you must want to do some action 

503 # 

504 argument_spec = purefb_argument_spec() 

505 argument_spec.update( 

506 dict( 

507 state=dict( 

508 type="str", 

509 default="present", 

510 choices=["absent", "present", "import", "export", "sign"], 

511 ), 

512 generate=dict(type="bool", default=False), 

513 name=dict(type="str", required=True), 

514 certificate_type=dict(type="str", choices=["external", "array"]), 

515 country=dict(type="str"), 

516 province=dict(type="str"), 

517 locality=dict(type="str"), 

518 organization=dict(type="str"), 

519 org_unit=dict(type="str"), 

520 common_name=dict(type="str"), 

521 email=dict(type="str"), 

522 key_size=dict(type="int", choices=[1024, 2048, 4096]), 

523 certificate=dict(type="str", no_log=True, aliases=["contents"]), 

524 intermediate_cert=dict( 

525 type="str", no_log=True, aliases=["intermeadiate_cert"] 

526 ), 

527 key=dict(type="str", no_log=True, aliases=["private_key"]), 

528 export_file=dict(type="str"), 

529 passphrase=dict(type="str", no_log=True), 

530 days=dict(type="int"), 

531 key_algorithm=dict(type="str", choices=["rsa", "ec", "ed448", "ed25519"]), 

532 subject_alternative_names=dict(type="list", elements="str"), 

533 ) 

534 ) 

535 

536 mutually_exclusive = [["certificate", "key_size"]] 

537 required_if = [ 

538 ["state", "import", ["certificate"]], 

539 ["state", "export", ["export_file"]], 

540 ["state", "sign", ["export_file"]], 

541 ] 

542 

543 module = AnsibleModule( 

544 argument_spec, 

545 mutually_exclusive=mutually_exclusive, 

546 required_if=required_if, 

547 supports_check_mode=True, 

548 ) 

549 

550 if not HAS_PYPURECLIENT: 

551 module.fail_json(msg="py-pure-client sdk is required for this module") 

552 

553 if not HAS_PYCOUNTRY: 

554 module.fail_json(msg="pycountry sdk is required for this module") 

555 

556 email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" 

557 blade = get_system(module) 

558 api_versions = get_rest_api_version(blade) 

559 

560 if module.params["email"]: 

561 if not re.search(email_pattern, module.params["email"]): 

562 module.fail_json( 

563 msg="Email {0} is not valid".format(module.params["email"]) 

564 ) 

565 if module.params["country"]: 

566 if len(module.params["country"]) != 2: 

567 module.fail_json(msg="Country must be a two-letter country (ISO) code") 

568 if not pycountry.countries.get(alpha_2=module.params["country"].upper()): 

569 module.fail_json( 

570 msg="Country code {0} is not an assigned ISO 3166-1 code".format( 

571 module.params["country"].upper() 

572 ) 

573 ) 

574 

575 if not module.params["certificate_type"]: 

576 if module.params["key"] or not module.params["certificate"]: 

577 module.params["certificate_type"] = "array" 

578 else: 

579 module.params["certificate_type"] = "external" 

580 

581 state = module.params["state"] 

582 certificate_type = module.params["certificate_type"] 

583 

584 exists = bool( 

585 blade.get_certificates(names=[module.params["name"]]).status_code == 200 

586 ) 

587 

588 if not exists and state == "present": 

589 create_cert(module, blade) 

590 elif exists and state == "present": 

591 update_cert(module, blade) 

592 elif state == "sign": 

593 if LooseVersion(CSR_API_VERSION) > LooseVersion(api_versions): 

594 module.fail_json(msg="Purity//FB 4.6.3+ is required for CSRs") 

595 create_csr(module, blade) 

596 elif not exists and state == "import": 

597 create_cert(module, blade) 

598 elif exists and state == "import" and certificate_type == "external": 

599 module.fail_json(msg="External Certificates cannot be reimported") 

600 elif exists and state == "import": 

601 update_cert(module, blade) 

602 elif state == "export": 

603 export_cert(module, blade) 

604 elif exists and state == "absent": 

605 delete_cert(module, blade) 

606 

607 module.exit_json(changed=False) 

608 

609 

610if __name__ == "__main__": 

611 main()