diff --git a/lib/ansible/modules/cloud/google/gce_labels.py b/lib/ansible/modules/cloud/google/gce_labels.py new file mode 100644 index 0000000000..77e84c2f5f --- /dev/null +++ b/lib/ansible/modules/cloud/google/gce_labels.py @@ -0,0 +1,333 @@ +#!/usr/bin/python +# Copyright 2017 Google Inc. +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +ANSIBLE_METADATA = {'metadata_version': '1.0', + 'status': ['preview'], + 'supported_by': 'community'} + + +DOCUMENTATION = ''' +--- +module: gce_labels +version_added: '2.4' +short_description: Create, Update or Destory GCE Labels. +description: + - Create, Update or Destory GCE Labels on instances, disks, snapshots, etc. + When specifying the GCE resource, users may specifiy the full URL for + the resource (its 'self_link'), or the individual parameters of the + resource (type, location, name). Examples for the two options can be + seen in the documentaion. + See U(https://cloud.google.com/compute/docs/label-or-tag-resources) for + more information about GCE Labels. Labels are gradually being added to + more GCE resources, so this module will need to be updated as new + resources are added to the GCE (v1) API. +requirements: + - 'python >= 2.6' + - 'google-api-python-client >= 1.6.2' + - 'google-auth >= 1.0.0' + - 'google-auth-httplib2 >= 0.0.2' +notes: + - Labels support resources such as instances, disks, images, etc. See + U(https://cloud.google.com/compute/docs/labeling-resources) for the list + of resources available in the GCE v1 API (not alpha or beta). +author: + - 'Eric Johnson (@erjohnso) ' +options: + labels: + description: + - A list of labels (key/value pairs) to add or remove for the resource. + required: false + resource_url: + description: + - The 'self_link' for the resource (instance, disk, snapshot, etc) + required: false + resource_type: + description: + - The type of resource (instances, disks, snapshots, images) + required: false + resource_location: + description: + - The location of resource (global, us-central1-f, etc.) + required: false + resource_name: + description: + - The name of resource. + required: false +''' + +EXAMPLES = ''' +- name: Add labels on an existing instance (using resource_url) + gce_labels: + service_account_email: "{{ service_account_email }}" + credentials_file: "{{ credentials_file }}" + project_id: "{{ project_id }}" + labels: + webserver-frontend: homepage + environment: test + experiment-name: kennedy + resource_url: https://www.googleapis.com/compute/beta/projects/myproject/zones/us-central1-f/instances/example-instance + state: present +- name: Add labels on an image (using resource params) + gce_labels: + service_account_email: "{{ service_account_email }}" + credentials_file: "{{ credentials_file }}" + project_id: "{{ project_id }}" + labels: + webserver-frontend: homepage + environment: test + experiment-name: kennedy + resource_type: images + resource_location: global + resource_name: my-custom-image + state: present +- name: Remove specified labels from the GCE instance + gce_labels: + service_account_email: "{{ service_account_email }}" + credentials_file: "{{ credentials_file }}" + project_id: "{{ project_id }}" + labels: + environment: prod + experiment-name: kennedy + resource_url: https://www.googleapis.com/compute/beta/projects/myproject/zones/us-central1-f/instances/example-instance + state: absent +''' + +RETURN = ''' +labels: + description: List of labels that exist on the resource. + returned: Always. + type: dict + sample: [ { 'webserver-frontend': 'homepage', 'environment': 'test', 'environment-name': 'kennedy' } ] +resource_url: + description: The 'self_link' of the GCE resource. + returned: Always. + type: str + sample: 'https://www.googleapis.com/compute/beta/projects/myproject/zones/us-central1-f/instances/example-instance' +resource_type: + description: The type of the GCE resource. + returned: Always. + type: str + sample: instances +resource_location: + description: The location of the GCE resource. + returned: Always. + type: str + sample: us-central1-f +resource_name: + description: The name of the GCE resource. + returned: Always. + type: str + sample: my-happy-little-instance +state: + description: state of the labels + returned: Always. + type: str + sample: present +''' + +try: + from ast import literal_eval + HAS_PYTHON26 = True +except ImportError: + HAS_PYTHON26 = False + +# import module snippets +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.gcp import check_params, get_google_api_client, GCPUtils + +UA_PRODUCT = 'ansible-gce_labels' +UA_VERSION = '0.0.1' +GCE_API_VERSION = 'v1' + +# TODO(all): As Labels are added to more GCE resources, this list will need to +# be updated (along with some code changes below). The list can *only* include +# resources from the 'v1' GCE API and will *not* work with 'beta' or 'alpha'. +KNOWN_RESOURCES = ['instances', 'disks', 'snapshots', 'images'] + + +def _fetch_resource(client, module): + params = module.params + if params['resource_url']: + if not params['resource_url'].startswith('https://www.googleapis.com/compute'): + module.fail_json( + msg='Invalid self_link url: %s' % params['resource_url']) + else: + parts = params['resource_url'].split('/')[8:] + if len(parts) == 2: + resource_type, resource_name = parts + resource_location = 'global' + else: + resource_location, resource_type, resource_name = parts + else: + if not params['resource_type'] or not params['resource_location'] \ + or not params['resource_name']: + module.fail_json(msg='Missing required resource params.') + resource_type = params['resource_type'].lower() + resource_name = params['resource_name'].lower() + resource_location = params['resource_location'].lower() + + if resource_type not in KNOWN_RESOURCES: + module.fail_json(msg='Unsupported resource_type: %s' % resource_type) + + # TODO(all): See the comment above for KNOWN_RESOURCES. As labels are + # added to the v1 GCE API for more resources, some minor code work will + # need to be added here. + if resource_type == 'instances': + resource = client.instances().get(project=params['project_id'], + zone=resource_location, + instance=resource_name).execute() + elif resource_type == 'disks': + resource = client.disks().get(project=params['project_id'], + zone=resource_location, + disk=resource_name).execute() + elif resource_type == 'snapshots': + resource = client.snapshots().get(project=params['project_id'], + snapshot=resource_name).execute() + elif resource_type == 'images': + resource = client.images().get(project=params['project_id'], + image=resource_name).execute() + else: + module.fail_json(msg='Unsupported resource type: %s' % resource_type) + + return resource.get('labelFingerprint', ''), { + 'resource_name': resource.get('name'), + 'resource_url': resource.get('selfLink'), + 'resource_type': resource_type, + 'resource_location': resource_location, + 'labels': resource.get('labels', {}) + } + + +def _set_labels(client, new_labels, module, ri, fingerprint): + params = module.params + result = err = None + labels = { + 'labels': new_labels, + 'labelFingerprint': fingerprint + } + + # TODO(all): See the comment above for KNOWN_RESOURCES. As labels are + # added to the v1 GCE API for more resources, some minor code work will + # need to be added here. + if ri['resource_type'] == 'instances': + req = client.instances().setLabels(project=params['project_id'], + instance=ri['resource_name'], + zone=ri['resource_location'], + body=labels) + elif ri['resource_type'] == 'disks': + req = client.disks().setLabels(project=params['project_id'], + zone=ri['resource_location'], + resource=ri['resource_name'], + body=labels) + elif ri['resource_type'] == 'snapshots': + req = client.snapshots().setLabels(project=params['project_id'], + resource=ri['resource_name'], + body=labels) + elif ri['resource_type'] == 'images': + req = client.images().setLabels(project=params['project_id'], + resource=ri['resource_name'], + body=labels) + else: + module.fail_json(msg='Unsupported resource type: %s' % ri['resource_type']) + + # TODO(erjohnso): Once Labels goes GA, we'll be able to use the GCPUtils + # method to poll for the async request/operation to complete before + # returning. However, during 'beta', we are in an odd state where + # API requests must be sent to the 'compute/beta' API, but the python + # client library only allows for *Operations.get() requests to be + # sent to 'compute/v1' API. The response operation is in the 'beta' + # API-scope, but the client library cannot find the operation (404). + # result = GCPUtils.execute_api_client_req(req, client=client, raw=False) + # return result, err + result = req.execute() + return True, err + + +def main(): + module = AnsibleModule( + argument_spec=dict( + state=dict(choices=['absent', 'present'], default='present'), + service_account_email=dict(), + service_account_permissions=dict(type='list'), + pem_file=dict(), + credentials_file=dict(), + labels=dict(required=False, type='dict', default={}), + resource_url=dict(required=False, type='str'), + resource_name=dict(required=False, type='str'), + resource_location=dict(required=False, type='str'), + resource_type=dict(required=False, type='str'), + project_id=dict() + ), + required_together=[ + ['resource_name', 'resource_location', 'resource_type'] + ], + mutually_exclusive=[ + ['resource_url', 'resource_name'], + ['resource_url', 'resource_location'], + ['resource_url', 'resource_type'] + ] + ) + + if not HAS_PYTHON26: + module.fail_json( + msg="GCE module requires python's 'ast' module, python v2.6+") + + client, cparams = get_google_api_client(module, 'compute', + user_agent_product=UA_PRODUCT, + user_agent_version=UA_VERSION, + api_version=GCE_API_VERSION) + + # Get current resource info including labelFingerprint + fingerprint, resource_info = _fetch_resource(client, module) + new_labels = resource_info['labels'].copy() + + update_needed = False + if module.params['state'] == 'absent': + for k, v in module.params['labels'].items(): + if k in new_labels: + if new_labels[k] == v: + update_needed = True + new_labels.pop(k, None) + else: + module.fail_json(msg="Could not remove unmatched label pair '%s':'%s'" % (k, v)) + else: + for k, v in module.params['labels'].items(): + if k not in new_labels: + update_needed = True + new_labels[k] = v + + changed = False + json_output = {'state': module.params['state']} + if update_needed: + changed, err = _set_labels(client, new_labels, module, resource_info, + fingerprint) + json_output['changed'] = changed + + # TODO(erjohnso): probably want to re-fetch the resource to return the + # new labelFingerprint, check that desired labels match updated labels. + # BUT! Will need to wait for setLabels() to hit v1 API so we can use the + # GCPUtils feature to poll for the operation to be complete. For now, + # we'll just update the output with what we have from the original + # state of the resource. + json_output.update(resource_info) + json_output.update(module.params) + + module.exit_json(**json_output) + +if __name__ == '__main__': + main() diff --git a/test/integration/gce.yml b/test/integration/gce.yml index b90a4e7396..72fb420482 100644 --- a/test/integration/gce.yml +++ b/test/integration/gce.yml @@ -10,4 +10,5 @@ - { role: test_gcp_url_map, tags: test_gcp_url_map } - { role: test_gcp_glb, tags: test_gcp_glb } - { role: test_gcp_healthcheck, tags: test_gcp_healthcheck } + - { role: test_gce_labels, tags: test_gce_labels } # TODO: tests for gce_lb, gc_storage diff --git a/test/integration/roles/test_gce_labels/defaults/main.yml b/test/integration/roles/test_gce_labels/defaults/main.yml new file mode 100644 index 0000000000..70843951d5 --- /dev/null +++ b/test/integration/roles/test_gce_labels/defaults/main.yml @@ -0,0 +1,9 @@ +--- +# defaults file for test_gce_labels +instance_name: "{{ resource_prefix|lower }}" +service_account_email: "{{ gce_service_account_email }}" +pem_file: "{{ gce_pem_file }}" +project_id: "{{ gce_project_id }}" +zone: "us-central1-f" +machine_type: f1-micro +image: debian-8 diff --git a/test/integration/roles/test_gce_labels/tasks/main.yml b/test/integration/roles/test_gce_labels/tasks/main.yml new file mode 100644 index 0000000000..3237c736c1 --- /dev/null +++ b/test/integration/roles/test_gce_labels/tasks/main.yml @@ -0,0 +1,5 @@ +--- +# test role for gce_labels +- include: setup.yml +- include: test.yml +- include: teardown.yml diff --git a/test/integration/roles/test_gce_labels/tasks/setup.yml b/test/integration/roles/test_gce_labels/tasks/setup.yml new file mode 100644 index 0000000000..f3eea843b6 --- /dev/null +++ b/test/integration/roles/test_gce_labels/tasks/setup.yml @@ -0,0 +1,20 @@ +# GCE Labels Setup. +# ============================================================ +- name: "Create instance for executing gce_labels tests" + gce: + instance_names: "{{ instance_name }}" + machine_type: "{{ machine_type }}" + image: "{{ image }}" + zone: "{{ zone }}" + project_id: "{{ project_id }}" + pem_file: "{{ pem_file }}" + service_account_email: "{{ service_account_email }}" + state: present + register: result + +- name: assert VM created + assert: + that: + - 'result.changed' + - 'result.instance_names[0] == "{{ instance_name }}"' + - 'result.state == "present"' diff --git a/test/integration/roles/test_gce_labels/tasks/teardown.yml b/test/integration/roles/test_gce_labels/tasks/teardown.yml new file mode 100644 index 0000000000..1213680ec6 --- /dev/null +++ b/test/integration/roles/test_gce_labels/tasks/teardown.yml @@ -0,0 +1,18 @@ +# GCE Labels Teardown. +# ============================================================ +- name: "Teardown instance used in gce_labels test" + gce: + instance_names: "{{ instance_name }}" + zone: "{{ zone }}" + project_id: "{{ project_id }}" + pem_file: "{{ pem_file }}" + service_account_email: "{{ service_account_email }}" + state: absent + register: result + +- name: assert VM removed + assert: + that: + - 'result.changed' + - 'result.instance_names[0] == "{{ instance_name }}"' + - 'result.state == "absent"' diff --git a/test/integration/roles/test_gce_labels/tasks/test.yml b/test/integration/roles/test_gce_labels/tasks/test.yml new file mode 100644 index 0000000000..605009efaf --- /dev/null +++ b/test/integration/roles/test_gce_labels/tasks/test.yml @@ -0,0 +1,28 @@ +# GCE Labels Integration Tests. + +## Parameter checking tests ## +# ============================================================ +- name: "test unknown resource_type" + gce_labels: + service_account_email: "{{ service_account_email }}" + pem_file: "{{ pem_file }}" + project_id: "{{ project_id }}" + resource_type: doggie + resource_location: Kansas + resource_name: Toto + labels: + environment: dev + experiment: kennedy + register: result + ignore_errors: true + labels: + - param-check + +- name: "assert failure when param: unknown resource_type" + assert: + that: + - 'result.failed' + - 'result.msg == "Unsupported resource_type: doggie"' + + +# TODO(erjohnso): write more tests