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
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-30 17:06 -0400
1#!/usr/bin/python
2# -*- coding: utf-8 -*-
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)
7from __future__ import absolute_import, division, print_function
9__metaclass__ = type
11ANSIBLE_METADATA = {
12 "metadata_version": "1.1",
13 "status": ["preview"],
14 "supported_by": "community",
15}
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"""
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
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
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
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
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
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"""
205RETURN = r"""
206"""
208HAS_PYPURECLIENT = True
209try:
210 from pypureclient.flashblade import (
211 CertificatePost,
212 CertificateSigningRequestPost,
213 Reference,
214 CertificatePatch,
215 )
216except ImportError:
217 HAS_PYPURECLIENT = False
219HAS_PYCOUNTRY = True
220try:
221 import pycountry
222except ImportError:
223 HAS_PYCOUNTRY = False
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)
239CERT_TYPE_VERSION = "2.15"
240CSR_API_VERSION = "2.20"
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}
259def _text_differs(requested, current):
260 """Compare PEM text ignoring surrounding whitespace"""
261 return (requested or "").strip() != (current or "").strip()
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]
276def cert_changes(module, current):
277 """Return the names of the options whose requested value differs
278 from the existing certificate.
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
311def update_cert(module, blade):
312 """Update existing SSL Certificate
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"])
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)
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 )
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 )
437 module.exit_json(changed=changed)
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)
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)
471def create_csr(module, blade):
472 """Construct a Certificate Signing Request
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)
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 )
536 mutually_exclusive = [["certificate", "key_size"]]
537 required_if = [
538 ["state", "import", ["certificate"]],
539 ["state", "export", ["export_file"]],
540 ["state", "sign", ["export_file"]],
541 ]
543 module = AnsibleModule(
544 argument_spec,
545 mutually_exclusive=mutually_exclusive,
546 required_if=required_if,
547 supports_check_mode=True,
548 )
550 if not HAS_PYPURECLIENT:
551 module.fail_json(msg="py-pure-client sdk is required for this module")
553 if not HAS_PYCOUNTRY:
554 module.fail_json(msg="pycountry sdk is required for this module")
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)
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 )
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"
581 state = module.params["state"]
582 certificate_type = module.params["certificate_type"]
584 exists = bool(
585 blade.get_certificates(names=[module.params["name"]]).status_code == 200
586 )
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)
607 module.exit_json(changed=False)
610if __name__ == "__main__":
611 main()