updated plugsins based on feedback, fixed linting and documentation errors.

This commit is contained in:
Dave Costakos 2023-07-14 10:33:15 -07:00
commit 40d2c9a7d5
No known key found for this signature in database
GPG key ID: C4DC31A1B32AC45C

View file

@ -1,25 +1,35 @@
#!/usr/bin/env python #!/usr/bin/python
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # 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 # SPDX-License-Identifier: GPL-3.0-or-later
################################################################################ ################################################################################
# Documentation # Documentation
################################################################################ ################################################################################
from __future__ import absolute_import, division, print_function
__metaclass__ = type
ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ["preview"], 'supported_by': 'community'} ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ["preview"], 'supported_by': 'community'}
DOCUMENTATION=''' DOCUMENTATION = '''
--- ---
module: gcp_secret_manager module: gcp_secret_manager
description: description:
- Simple create/delete of secrets - Access secrets stored in Google Secrets Manager.
- add or delete secret versions - Create new secrets.
- other features like etags, replication, annontation expected to be managed outside of Ansible - Create new secret values.
- Add/remove versions of secrets.
- Please note that other features like etags, replication, annontation expected to be managed outside of Ansible.
short_description: Access and Update Google Cloud Secrets Manager objects
author: Dave Costakos @RedHat
requirements: requirements:
- python >= 2.6 - python >= 2.6
- requests >= 2.18.4 - requests >= 2.18.4
- google-auth >= 1.3.0 - google-auth >= 1.3.0
options:
options: options:
project: project:
description: description:
@ -57,6 +67,7 @@ options:
description: description:
- Name of the secret to be used - Name of the secret to be used
type: str type: str
required: true
aliases: aliases:
- key - key
- secret - secret
@ -69,7 +80,11 @@ options:
type: str type: str
state: state:
description: description:
- 'absent' or 'present': whether the secret should exist - whether the secret should exist
default: present
choices:
- absent
- present
type: str type: str
return_value: return_value:
description: description:
@ -81,19 +96,31 @@ options:
description: description:
- A version label to apply to the secret - A version label to apply to the secret
- Default is "latest" which is the newest version of the secret - Default is "latest" which is the newest version of the secret
- "all" is also acceptable on delete (which will delete all versions of a secret) - The special "all" is also acceptable on delete (which will delete all versions of a secret)
type: str type: str
default: 'latest' default: latest
labels: labels:
description: description:
- A set of key-value pairs to assign as labels to asecret - A set of key-value pairs to assign as labels to asecret
- only used in creation - only used in creation
- Note that the "value" piece of a label must contain only readable chars - Note that the "value" piece of a label must contain only readable chars
type: dict type: dict
required: False notes:
- 'API Reference: U(https://cloud.google.com/secret-manager/docs/reference/rests)'
- 'Official Documentation: U(https://cloud.google.com/secret-manager/docs/overview)'
- for authentication, you can set service_account_file using the C(GCP_SERVICE_ACCOUNT_FILE)
env variable.
- for authentication, you can set service_account_contents using the C(GCP_SERVICE_ACCOUNT_CONTENTS)
env variable.
- For authentication, you can set service_account_email using the C(GCP_SERVICE_ACCOUNT_EMAIL)
env variable.
- For authentication, you can set auth_kind using the C(GCP_AUTH_KIND) env variable.
- For authentication, you can set scopes using the C(GCP_SCOPES) env variable.
- Environment variables values will only be used if the playbook values are not set.
- The I(service_account_email) and I(service_account_file) options are mutually exclusive.
''' '''
EXAMPLES=''' EXAMPLES = r'''
- name: Create a new secret - name: Create a new secret
google.cloud.gcp_secret_manager: google.cloud.gcp_secret_manager:
name: secret_key name: secret_key
@ -110,7 +137,7 @@ EXAMPLES='''
- name: Ensure secret exists but don't return the value - name: Ensure secret exists but don't return the value
google.cloud.gcp_secret_manager: google.cloud.gcp_secret_manager:
name: secret_key name: secret_key
state: present state: present
return_value: false return_value: false
- name: Add a new version of a secret - name: Add a new version of a secret
@ -131,9 +158,15 @@ EXAMPLES='''
version: all version: all
state: absent state: absent
- name: Create a secret with labels
google.cloud.gcp_secret_manager:
name: secret_key
value: super_secret
labels:
key_name: "ansible_rox"
''' '''
RETURN = ''' RETURN = r'''
resources: resources:
description: List of resources description: List of resources
returned: always returned: always
@ -182,33 +215,35 @@ resources:
from ansible_collections.google.cloud.plugins.module_utils.gcp_utils import ( from ansible_collections.google.cloud.plugins.module_utils.gcp_utils import (
navigate_hash, navigate_hash,
GcpSession, GcpSession,
GcpModule, GcpModule
GcpRequest,
remove_nones_from_dict,
replace_resource_dict,
) )
import json
# for decoding and validating secrets # for decoding and validating secrets
import json
import base64 import base64
import binascii import copy
def get_auth(module): def get_auth(module):
return GcpSession(module, 'secret-manager') return GcpSession(module, 'secret-manager')
def self_access_link(module): def self_access_link(module):
return "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}/versions/{calc_version}:access".format(**module.params) return "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}/versions/{calc_version}:access".format(**module.params)
def self_get_link(module): def self_get_link(module):
return "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}/versions/{calc_version}".format(**module.params) return "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}/versions/{calc_version}".format(**module.params)
def self_update_link(module): def self_update_link(module):
return "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}/versions/{calc_version:version}".format(**module.params) return "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}/versions/{calc_version:version}".format(**module.params)
def self_list_link(module): def self_list_link(module):
return "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}/versions?filter=state:ENABLED".format(**module.params) return "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}/versions?filter=state:ENABLED".format(**module.params)
def self_delete_link(module): def self_delete_link(module):
return "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}".format(**module.params) return "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}".format(**module.params)
@ -216,7 +251,7 @@ def self_delete_link(module):
def fetch_resource(module, allow_not_found=True): def fetch_resource(module, allow_not_found=True):
auth = get_auth(module) auth = get_auth(module)
# set version to the latest version because # set version to the latest version because
# we can't be sure that "latest" is always going # we can't be sure that "latest" is always going
# to be set if secret versions get disabled # to be set if secret versions get disabled
# see https://issuetracker.google.com/issues/286489671 # see https://issuetracker.google.com/issues/286489671
if module.params['version'] == "latest" or module.params['version'] == 'all': if module.params['version'] == "latest" or module.params['version'] == 'all':
@ -224,7 +259,7 @@ def fetch_resource(module, allow_not_found=True):
latest_version = None latest_version = None
if version_list is None: if version_list is None:
return None return None
if "versions" in version_list: if "versions" in version_list:
latest_version = sorted(version_list['versions'], key=lambda d: d['name'])[-1]['name'].split('/')[-1] latest_version = sorted(version_list['versions'], key=lambda d: d['name'])[-1]['name'].split('/')[-1]
module.params['calc_version'] = latest_version module.params['calc_version'] = latest_version
@ -233,7 +268,7 @@ def fetch_resource(module, allow_not_found=True):
# handle the corner case that we tried to delete # handle the corner case that we tried to delete
# a secret version that doesn't exist # a secret version that doesn't exist
if module.params['state'] == "absent": if module.params['state'] == "absent":
return { "action": "delete_secret" } return {"action": "delete_secret"}
link = self_access_link(module) link = self_access_link(module)
access_obj = return_if_object(module, auth.get(link), allow_not_found) access_obj = return_if_object(module, auth.get(link), allow_not_found)
@ -245,29 +280,17 @@ def fetch_resource(module, allow_not_found=True):
return None return None
return merge_dicts(get_obj, access_obj) return merge_dicts(get_obj, access_obj)
def merge_dicts(x, y): def merge_dicts(x, y):
z = x.copy() z = copy.deepcopy(x)
z.update(y) z.update(y)
return z return z
def snake_to_camel(snake):
result = ''
capitalize_next = False
for char in snake:
if char == '_':
capitalize_next = True
else:
if capitalize_next:
result += char.upper()
capitalize_next = False
else:
result += char
return str(result)
# create secret is a create call + an add version call # create secret is a create call + an add version call
def create_secret(module): def create_secret(module):
# build the payload # build the payload
payload = { "replication": { "automatic": {} } } payload = {"replication": {"automatic": {}}}
if module.params['labels']: if module.params['labels']:
payload['labels'] = module.params['labels'] payload['labels'] = module.params['labels']
@ -278,6 +301,7 @@ def create_secret(module):
module.raise_for_status(post_response) module.raise_for_status(post_response)
return update_secret(module) return update_secret(module)
def update_secret(module): def update_secret(module):
# build the payload # build the payload
b64_value = base64.b64encode(module.params['value'].encode("utf-8")).decode("utf-8") b64_value = base64.b64encode(module.params['value'].encode("utf-8")).decode("utf-8")
@ -290,12 +314,14 @@ def update_secret(module):
url = "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}:addVersion".format(**module.params) url = "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}:addVersion".format(**module.params)
return return_if_object(module, auth.post(url, payload), False) return return_if_object(module, auth.post(url, payload), False)
def list_secret_versions(module): def list_secret_versions(module):
# filter by only enabled secrets # filter by only enabled secrets
url = self_list_link(module) url = self_list_link(module)
auth = get_auth(module) auth = get_auth(module)
return return_if_object(module, auth.get(url), True) return return_if_object(module, auth.get(url), True)
# technically we're destroying the version # technically we're destroying the version
def delete_secret(module, destroy_all=False): def delete_secret(module, destroy_all=False):
# delete secret does not take "latest" as a default version # delete secret does not take "latest" as a default version
@ -303,24 +329,25 @@ def delete_secret(module, destroy_all=False):
version = module.params['version'] version = module.params['version']
auth = get_auth(module) auth = get_auth(module)
if version.lower() == "all" or destroy_all: if version.lower() == "all" or destroy_all:
url = self_delete_link(module) url = self_delete_link(module)
return return_if_object(module, auth.delete(url)) return return_if_object(module, auth.delete(url))
else: else:
url = self_get_link(module) + ":destroy" url = self_get_link(module) + ":destroy"
return return_if_object(module, auth.post(url, {}), False) return return_if_object(module, auth.post(url, {}), False)
def return_if_object(module, response, allow_not_found=False): def return_if_object(module, response, allow_not_found=False):
# If not found, return nothing. # If not found, return nothing.
if allow_not_found and response.status_code == 404: if allow_not_found and response.status_code == 404:
return None return None
if response.status_code == 409: if response.status_code == 409:
module.params['info'] = "exists already" module.params['info'] = "exists already"
return None; return None
# probably a code error # probably a code error
if response.status_code == 400: if response.status_code == 400:
module.fail_json(msg=f"unexpected REST failure: {response.json()['error']}") module.fail_json(msg="unexpected REST failure: %s" % response.json()['error'])
# If no content, return nothing. # If no content, return nothing.
if response.status_code == 204: if response.status_code == 204:
@ -334,11 +361,11 @@ def return_if_object(module, response, allow_not_found=False):
if "name" in result: if "name" in result:
result['version'] = result['name'].split("/")[-1] result['version'] = result['name'].split("/")[-1]
result['name'] = result['name'].split("/")[3] result['name'] = result['name'].split("/")[3]
# base64 decode the value # base64 decode the value
if "payload" in result and "data" in result['payload']: if "payload" in result and "data" in result['payload']:
result['value'] = base64.b64decode(result['payload']['data']).decode("utf-8") result['value'] = base64.b64decode(result['payload']['data']).decode("utf-8")
except getattr(json.decoder, 'JSONDecodeError', ValueError): except getattr(json.decoder, 'JSONDecodeError', ValueError):
module.fail_json(msg="Invalid JSON response with error: %s" % response.text) module.fail_json(msg="Invalid JSON response with error: %s" % response.text)
@ -351,7 +378,7 @@ def return_if_object(module, response, allow_not_found=False):
def main(): def main():
# limited support for parameters described in the "Secret" resource # limited support for parameters described in the "Secret" resource
# in order to simplify and deploy primary use cases # in order to simplify and deploy primary use cases
# expectation is customers needing to support additional capabilities # expectation is customers needing to support additional capabilities
# in the SecretPayload will do so outside of Ansible. # in the SecretPayload will do so outside of Ansible.
# ref: https://cloud.google.com/secret-manager/docs/reference/rest/v1/projects.secrets#Secret # ref: https://cloud.google.com/secret-manager/docs/reference/rest/v1/projects.secrets#Secret
module = GcpModule( module = GcpModule(
@ -365,7 +392,6 @@ def main():
) )
) )
if not module.params['scopes']: if not module.params['scopes']:
module.params['scopes'] = ["https://www.googleapis.com/auth/cloud-platform"] module.params['scopes'] = ["https://www.googleapis.com/auth/cloud-platform"]
@ -386,11 +412,11 @@ def main():
# fail, let the user know # fail, let the user know
# that no secret could be created without a value to encrypt # that no secret could be created without a value to encrypt
elif state == 'present': elif state == 'present':
module.fail_json(msg="secret '{name}' not present in '{project}' and no value for the secret is provided".format(**module.params)), module.fail_json(msg="secret '{name}' not present in '{project}' and no value for the secret is provided".format(**module.params))
# secret is absent, success # secret is absent, success
else: else:
fetch = { "msg": "secret '{name}' in project '{project}' not present".format(**module.params)} fetch = {"msg": "secret '{name}' in project '{project}' not present".format(**module.params)}
else: else:
# delete the secret version (latest if no version is specified) # delete the secret version (latest if no version is specified)
@ -404,26 +430,25 @@ def main():
if "value" in fetch and module.params.get('value') is not None: if "value" in fetch and module.params.get('value') is not None:
# Update secret # Update secret
if fetch['value'] != module.params['value']: if fetch['value'] != module.params['value']:
update = update_secret(module) update_secret(module)
changed = True changed = True
else: else:
fetch['msg'] = "values identical, no need to update secret" fetch['msg'] = "values identical, no need to update secret"
# pop value data if return_value == false # pop value data if return_value == false
if module.params['return_value'] == False: if module.params['return_value'] is False:
fetch.pop('value') fetch.pop('value')
fetch.pop('payload') fetch.pop('payload')
if "msg" in fetch: if "msg" in fetch:
fetch['msg'] = f"{fetch['msg']} | not returning secret value since 'return_value is set to false" fetch['msg'] = f"{fetch['msg']} | not returning secret value since 'return_value is set to false"
else: else:
fetch['msg'] = "not returning secret value since 'return_value is set to false" fetch['msg'] = "not returning secret value since 'return_value is set to false"
fetch['changed'] = changed fetch['changed'] = changed
fetch['name'] = module.params['name'] fetch['name'] = module.params['name']
module.exit_json(**fetch) module.exit_json(**fetch)
if __name__ == "__main__": if __name__ == "__main__":
main() main()