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