diff --git a/lib/ansible/module_utils/gcp_utils.py b/lib/ansible/module_utils/gcp_utils.py new file mode 100644 index 00000000000..a938b5237c4 --- /dev/null +++ b/lib/ansible/module_utils/gcp_utils.py @@ -0,0 +1,154 @@ +# Copyright (c), Google Inc, 2017 +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +try: + import requests + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + +try: + import google.auth + import google.auth.compute_engine + from google.oauth2 import service_account + from google.auth.transport.requests import AuthorizedSession + HAS_GOOGLE_LIBRARIES = True +except ImportError: + HAS_GOOGLE_LIBRARIES = False + +from ansible.module_utils.basic import AnsibleModule, env_fallback +import os + + +def navigate_hash(source, path, default=None): + key = path[0] + path = path[1:] + if key not in source: + return default + result = source[key] + if path: + return navigate_hash(result, path, default) + else: + return result + + +class GcpRequestException(Exception): + pass + + +# Handles all authentation and HTTP sessions for GCP API calls. +class GcpSession(object): + def __init__(self, module, product): + self.module = module + self.product = product + self._validate() + + def get(self, url, body=None): + try: + return self.session().get(url, json=body, headers=self._headers()) + except getattr(requests.exceptions, 'RequestException') as inst: + raise GcpRequestException(inst.message) + + def post(self, url, body=None): + try: + return self.session().post(url, json=body, headers=self._headers()) + except getattr(requests.exceptions, 'RequestException') as inst: + raise GcpRequestException(inst.message) + + def delete(self, url, body=None): + try: + return self.session().delete(url, json=body, headers=self._headers()) + except getattr(requests.exceptions, 'RequestException') as inst: + raise GcpRequestException(inst.message) + + def put(self, url, body=None): + try: + return self.session().put(url, json=body, headers=self._headers()) + except getattr(requests.exceptions, 'RequestException') as inst: + raise GcpRequestException(inst.message) + + def session(self): + return AuthorizedSession( + self._credentials().with_scopes(self.module.params['scopes'])) + + def _validate(self): + if not HAS_REQUESTS: + self.module.fail_json(msg="Please install the requests library") + + if not HAS_GOOGLE_LIBRARIES: + self.module.fail_json(msg="Please install the google-auth library") + + if self.module.params['service_account_email'] is not None and self.module.params['auth_kind'] != 'machineaccount': + self.module.fail_json( + msg="Service Acccount Email only works with Machine Account-based authentication" + ) + + if self.module.params['service_account_file'] is not None and self.module.params['auth_kind'] != 'serviceaccount': + self.module.fail_json( + msg="Service Acccount File only works with Service Account-based authentication" + ) + + def _credentials(self): + cred_type = self.module.params['auth_kind'] + if cred_type == 'application': + credentials, project_id = google.auth.default() + return credentials + elif cred_type == 'serviceaccount': + return service_account.Credentials.from_service_account_file( + self.module.params['service_account_file']) + elif cred_type == 'machineaccount': + return google.auth.compute_engine.Credentials( + self.module.params['service_account_email']) + else: + self.module.fail_json(msg="Credential type '%s' not implmented" % cred_type) + + def _headers(self): + return { + 'User-Agent': "Google-Ansible-MM-{0}".format(self.product) + } + + +class GcpModule(AnsibleModule): + def __init__(self, *args, **kwargs): + arg_spec = {} + if 'argument_spec' in kwargs: + arg_spec = kwargs['argument_spec'] + + kwargs['argument_spec'] = self._merge_dictionaries( + arg_spec, + dict( + project=dict(required=True, type='str'), + auth_kind=dict( + required=False, + fallback=(env_fallback, ['GCP_AUTH_KIND']), + choices=['machineaccount', 'serviceaccount', 'application'], + type='str'), + service_account_email=dict( + required=False, + fallback=(env_fallback, ['GCP_SERVICE_ACCOUNT_EMAIL']), + type='str'), + service_account_file=dict( + required=False, + fallback=(env_fallback, ['GCP_SERVICE_ACCOUNT_FILE']), + type='path'), + scopes=dict( + required=False, + fallback=(env_fallback, ['GCP_SCOPES']), + type='list') + ) + ) + + mutual = [] + if 'mutually_exclusive' in kwargs: + mutual = kwargs['mutually_exclusive'] + + kwargs['mutually_exclusive'] = mutual.append( + ['service_account_email', 'service_account_file'] + ) + + AnsibleModule.__init__(self, *args, **kwargs) + + def _merge_dictionaries(self, a, b): + new = a.copy() + new.update(b) + return new diff --git a/lib/ansible/modules/cloud/google/gcp_dns_managed_zone.py b/lib/ansible/modules/cloud/google/gcp_dns_managed_zone.py new file mode 100644 index 00000000000..7444dbd4f4f --- /dev/null +++ b/lib/ansible/modules/cloud/google/gcp_dns_managed_zone.py @@ -0,0 +1,242 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2017 Google +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# ---------------------------------------------------------------------------- +# +# *** AUTO GENERATED CODE *** AUTO GENERATED CODE *** +# +# ---------------------------------------------------------------------------- +# +# This file is automatically generated by Magic Modules and manual +# changes will be clobbered when the file is regenerated. +# +# Please read more about how to change this file at +# https://www.github.com/GoogleCloudPlatform/magic-modules +# +# ---------------------------------------------------------------------------- + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +################################################################################ +# Documentation +################################################################################ + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ["preview"], + 'supported_by': 'community'} + +DOCUMENTATION = ''' +--- +module: gcp_dns_managed_zone +description: + - A zone is a subtree of the DNS namespace under one administrative + responsibility. A ManagedZone is a resource that represents a DNS zone + hosted by the Cloud DNS service. +short_description: Creates a GCP ManagedZone +version_added: 2.5 +author: Google Inc. (@googlecloudplatform) +requirements: + - python >= 2.6 + - requests >= 2.18.4 + - google-auth >= 1.3.0 +options: + state: + description: + - Whether the given object should exist in GCP + required: true + choices: ['present', 'absent'] + default: 'present' + description: + description: + - A mutable string of at most 1024 characters associated with this + resource for the user's convenience. Has no effect on the managed + zone's function. + required: false + dns_name: + description: + - The DNS name of this managed zone, for instance "example.com.". + required: false + name: + description: + - User assigned name for this resource. + Must be unique within the project. + required: true + name_server_set: + description: + - Optionally specifies the NameServerSet for this ManagedZone. A + NameServerSet is a set of DNS name servers that all host the same + ManagedZones. Most users will leave this field unset. + required: false +extends_documentation_fragment: gcp +''' + +EXAMPLES = ''' +- name: Create a Managed Zone + gcp_dns_managed_zone: + name: testObject + dns_name: test.somewild2.example.com. + description: 'test zone' + project: testProject + auth_kind: service_account + service_account_file: /tmp/auth.pem + scopes: + - https://www.googleapis.com/auth/ndev.clouddns.readwrite + state: 'present' +''' + +RETURN = ''' + id: + description: + - Unique identifier for the resource; defined by the server. + returned: success + type: int + name_servers: + description: + - Delegate your managed_zone to these virtual name servers; + defined by the server + returned: success + type: list + creation_time: + description: + - The time that this resource was created on the server. + This is in RFC3339 text format. + returned: success + type: str +''' + +################################################################################ +# Imports +################################################################################ + +from ansible.module_utils.gcp_utils import navigate_hash, GcpSession, GcpModule, GcpRequestException +import json + +################################################################################ +# Main +################################################################################ + + +def main(): + """Main function""" + + module = GcpModule( + argument_spec=dict( + state=dict(default='present', choices=['present', 'absent'], type='str'), + description=dict(type='str'), + dns_name=dict(type='str'), + name=dict(required=True, type='str'), + name_server_set=dict(type='list'), + ) + ) + + state = module.params['state'] + kind = 'dns#managedZone' + + fetch = fetch_resource(module, self_link(module), kind) + changed = False + + if fetch: + if state == 'present': + if is_different(module, fetch): + fetch = update(module, self_link(module), kind) + else: + delete(module, self_link(module), kind) + fetch = {} + changed = True + else: + if state == 'present': + fetch = create(module, collection(module), kind) + changed = True + + if fetch: + fetch.update({'changed': changed}) + else: + fetch = {'changed': changed} + + module.exit_json(**fetch) + + +def create(module, link, kind): + auth = GcpSession(module, 'g') + return return_if_object(module, auth.post(link, resource_to_request(module)), kind) + + +def update(module, link, kind): + module.fail_json(msg="ManagedZone cannot be edited") + + +def delete(module, link, kind): + auth = GcpSession(module, 'g') + return return_if_object(module, auth.delete(link), kind) + + +def resource_to_request(module): + request = { + u'kind': 'dns#managedZone', + u'description': module.params['description'], + u'dnsName': module.params['dns_name'], + u'name': module.params['name'], + u'nameServerSet': module.params['name_server_set'], + } + return_vals = {} + for k, v in request.items(): + if v: + return_vals[k] = v + + return return_vals + + +def fetch_resource(module, link, kind): + auth = GcpSession(module, 'g') + return return_if_object(module, auth.get(link), kind) + + +def self_link(module): + return "https://www.googleapis.com/dns/v1/projects/{project}/managedZones/{name}".format(**module.params) + + +def collection(module): + return "https://www.googleapis.com/dns/v1/projects/{project}/managedZones".format(**module.params) + + +def return_if_object(module, response, kind): + # If not found, return nothing. + if response.status_code == 404: + return None + + # If no content, return nothing. + if response.status_code == 204: + return None + + try: + response.raise_for_status + result = response.json() + except getattr(json.decoder, 'JSONDecodeError', ValueError) as inst: + module.fail_json(msg="Invalid JSON response with error: %s" % inst) + except GcpRequestException as inst: + module.fail_json(msg="Network error: %s" % inst) + + if navigate_hash(result, ['error', 'errors']): + module.fail_json(msg=navigate_hash(result, ['error', 'errors'])) + if result['kind'] != kind: + module.fail_json(msg="Incorrect result: {kind}".format(**result)) + + return result + + +def is_different(module, response): + request = resource_to_request(module) + + # Remove all output-only from response. + return_vals = {} + for k, v in response.items(): + if k in request: + return_vals[k] = v + + return request != return_vals + +if __name__ == '__main__': + main() diff --git a/lib/ansible/utils/module_docs_fragments/gcp.py b/lib/ansible/utils/module_docs_fragments/gcp.py new file mode 100644 index 00000000000..9cc6e73f671 --- /dev/null +++ b/lib/ansible/utils/module_docs_fragments/gcp.py @@ -0,0 +1,47 @@ +# Copyright: (c) 2018, Google Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +class ModuleDocFragment(object): + # GCP doc fragment. + DOCUMENTATION = ''' +options: + state: + description: + - Whether the given zone should or should not be present. + required: true + choices: ["present", "absent"] + default: "present" + project: + description: + - The Google Cloud Platform project to use. + default: null + auth_kind: + description: + - The type of credential used. + required: true + choices: ["machineaccount", "serviceaccount", "application"] + service_account_file: + description: + - The path of a Service Account JSON file if serviceaccount is selected as type. + service_account_email: + description: + - An optional service account email address if machineaccount is selected + and the user does not wish to use the default email. + scopes: + description: + - Array of scopes to be used. + required: true +notes: + - For authentication, you can set service_account_file using the + C(GCP_SERVICE_ACCOUNT_FILE) 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. +''' diff --git a/test/integration/cloud-config-gcp.yml.template b/test/integration/cloud-config-gcp.yml.template new file mode 100644 index 00000000000..3ac2dccf7b8 --- /dev/null +++ b/test/integration/cloud-config-gcp.yml.template @@ -0,0 +1,17 @@ +# This is the configuration template for ansible-test GCP integration tests. +# +# You do not need this template if you are: +# +# 1) Running integration tests without using ansible-test. +# 2) Using the automatically provisioned cloudstack-sim docker container in ansible-test. +# +# If you do not want to use the automatically provided GCP simulator, +# fill in the @VAR placeholders below and save this file without the .template extension. +# This will cause ansible-test to use the given configuration and not launch the simulator. +# +# It is recommended that you DO NOT use this template unless you cannot use the simulator. + +gcp_project: @PROJECT +gcp_cred_file: @CRED_FILE +gcp_cred_kind: @CRED_KIND +gcp_cred_email: @CRED_EMAIL diff --git a/test/integration/targets/gcp_dns_managed_zone/aliases b/test/integration/targets/gcp_dns_managed_zone/aliases new file mode 100644 index 00000000000..26507c23cd3 --- /dev/null +++ b/test/integration/targets/gcp_dns_managed_zone/aliases @@ -0,0 +1 @@ +cloud/gcp diff --git a/test/integration/targets/gcp_dns_managed_zone/defaults/main.yml b/test/integration/targets/gcp_dns_managed_zone/defaults/main.yml new file mode 100644 index 00000000000..aa87a2a8e0e --- /dev/null +++ b/test/integration/targets/gcp_dns_managed_zone/defaults/main.yml @@ -0,0 +1,3 @@ +--- +# defaults file +resource_name: '{{resource_prefix}}' diff --git a/test/integration/targets/gcp_dns_managed_zone/meta/main.yml b/test/integration/targets/gcp_dns_managed_zone/meta/main.yml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/integration/targets/gcp_dns_managed_zone/tasks/main.yml b/test/integration/targets/gcp_dns_managed_zone/tasks/main.yml new file mode 100644 index 00000000000..e6ec5669f48 --- /dev/null +++ b/test/integration/targets/gcp_dns_managed_zone/tasks/main.yml @@ -0,0 +1,86 @@ +--- +# ---------------------------------------------------------------------------- +# +# *** AUTO GENERATED CODE *** AUTO GENERATED CODE *** +# +# ---------------------------------------------------------------------------- +# +# This file is automatically generated by Magic Modules and manual +# changes will be clobbered when the file is regenerated. +# +# Please read more about how to change this file at +# https://www.github.com/GoogleCloudPlatform/magic-modules +# +# ---------------------------------------------------------------------------- +#---------------------------------------------------------- +- name: create a managed zone + gcp_dns_managed_zone: + name: "{{ resource_name }}" + dns_name: test.somewild2.example.com. + description: 'test zone' + project: "{{ gcp_project }}" + auth_kind: "{{ gcp_cred_kind }}" + service_account_file: "{{ gcp_cred_file }}" + scopes: + - https://www.googleapis.com/auth/ndev.clouddns.readwrite + state: present + register: result +- name: assert changed is true + assert: + that: + - result.changed == true + - "result.kind == 'dns#managedZone'" +# ---------------------------------------------------------------------------- +- name: create a managed zone that already exists + gcp_dns_managed_zone: + name: "{{ resource_name }}" + dns_name: test.somewild2.example.com. + description: 'test zone' + project: "{{ gcp_project }}" + auth_kind: "{{ gcp_cred_kind }}" + service_account_file: "{{ gcp_cred_file }}" + scopes: + - https://www.googleapis.com/auth/ndev.clouddns.readwrite + state: present + register: result +- name: assert changed is false + assert: + that: + - result.changed == false + - "result.kind == 'dns#managedZone'" +#---------------------------------------------------------- +- name: delete a managed zone + gcp_dns_managed_zone: + name: "{{ resource_name }}" + dns_name: test.somewild2.example.com. + description: 'test zone' + project: "{{ gcp_project }}" + auth_kind: "{{ gcp_cred_kind }}" + service_account_file: "{{ gcp_cred_file }}" + scopes: + - https://www.googleapis.com/auth/ndev.clouddns.readwrite + state: absent + register: result +- name: assert changed is true + assert: + that: + - result.changed == true + - result.has_key('kind') == False +# ---------------------------------------------------------------------------- +- name: delete a managed zone that does not exist + gcp_dns_managed_zone: + name: "{{ resource_name }}" + dns_name: test.somewild2.example.com. + description: 'test zone' + project: "{{ gcp_project }}" + auth_kind: "{{ gcp_cred_kind }}" + service_account_file: "{{ gcp_cred_file }}" + scopes: + - https://www.googleapis.com/auth/ndev.clouddns.readwrite + state: absent + register: result +- name: assert changed is false + assert: + that: + - result.changed == false + - result.has_key('kind') == False diff --git a/test/runner/lib/cloud/gcp.py b/test/runner/lib/cloud/gcp.py new file mode 100644 index 00000000000..2f3255fbd94 --- /dev/null +++ b/test/runner/lib/cloud/gcp.py @@ -0,0 +1,67 @@ +# Copyright: (c) 2018, Google Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +"""GCP plugin for integration tests.""" +from __future__ import absolute_import, print_function + +import os + +from lib.util import ( + ApplicationError, + display, + is_shippable, +) + +from lib.cloud import ( + CloudProvider, + CloudEnvironment, +) + +from lib.core_ci import ( + AnsibleCoreCI, ) + + +class GcpCloudProvider(CloudProvider): + """GCP cloud provider plugin. Sets up cloud resources before delegation.""" + + def filter(self, targets, exclude): + """Filter out the cloud tests when the necessary config and resources are not available. + :type targets: tuple[TestTarget] + :type exclude: list[str] + """ + + if os.path.isfile(self.config_static_path): + return + + super(GcpCloudProvider, self).filter(targets, exclude) + + def setup(self): + """Setup the cloud resource before delegation and register a cleanup callback.""" + super(GcpCloudProvider, self).setup() + + if not self._use_static_config(): + display.notice( + 'static configuration could not be used. are you missing a template file?' + ) + + +class GcpCloudEnvironment(CloudEnvironment): + """GCP cloud environment plugin. Updates integration test environment after delegation.""" + + def configure_environment(self, env, cmd): + """ + :type env: dict[str, str] + :type cmd: list[str] + """ + cmd.append('-e') + cmd.append('@%s' % self.config_path) + + cmd.append('-e') + cmd.append('resource_prefix=%s' % self.resource_prefix) + + def on_failure(self, target, tries): + """ + :type target: TestTarget + :type tries: int + """ + if not tries and self.managed: + display.notice('%s failed' % target.name)