diff options
Diffstat (limited to 'heartbeat/gcp-vpc-move-vip.in')
-rwxr-xr-x | heartbeat/gcp-vpc-move-vip.in | 466 |
1 files changed, 466 insertions, 0 deletions
diff --git a/heartbeat/gcp-vpc-move-vip.in b/heartbeat/gcp-vpc-move-vip.in new file mode 100755 index 0000000..331226b --- /dev/null +++ b/heartbeat/gcp-vpc-move-vip.in @@ -0,0 +1,466 @@ +#!@PYTHON@ -tt +# --------------------------------------------------------------------- +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# --------------------------------------------------------------------- +# Description: Google Cloud Platform - Floating IP Address (Alias) +# --------------------------------------------------------------------- + +import json +import logging +import os +import sys +import time + +OCF_FUNCTIONS_DIR = os.environ.get("OCF_FUNCTIONS_DIR", "%s/lib/heartbeat" + % os.environ.get("OCF_ROOT")) +sys.path.append(OCF_FUNCTIONS_DIR) + +from ocf import * + +try: + import googleapiclient.discovery + try: + from google.oauth2.service_account import Credentials as ServiceAccountCredentials + except ImportError: + from oauth2client.service_account import ServiceAccountCredentials +except ImportError: + pass + +if sys.version_info >= (3, 0): + # Python 3 imports. + import urllib.parse as urlparse + import urllib.request as urlrequest +else: + # Python 2 imports. + import urllib as urlparse + import urllib2 as urlrequest + + +# Constants for alias add/remove modes +ADD = 0 +REMOVE = 1 + +CONN = None +THIS_VM = None +ALIAS = None +MAX_RETRIES = 3 +RETRY_BACKOFF_SECS = 1 +METADATA_SERVER = 'http://metadata.google.internal/computeMetadata/v1/' +METADATA_HEADERS = {'Metadata-Flavor': 'Google'} +METADATA = \ +'''<?xml version="1.0"?> +<!DOCTYPE resource-agent SYSTEM "ra-api-1.dtd"> +<resource-agent name="gcp-vpc-move-vip" version="1.0"> + <version>1.0</version> + <longdesc lang="en">Floating IP Address or Range on Google Cloud Platform - Using Alias IP address functionality to attach a secondary IP range to a running instance</longdesc> + <shortdesc lang="en">Floating IP Address or Range on Google Cloud Platform</shortdesc> + <parameters> + <parameter name="alias_ip" unique="1" required="1"> + <longdesc lang="en">IP range to be added including CIDR netmask (e.g., 192.168.0.1/32)</longdesc> + <shortdesc lang="en">IP range to be added including CIDR netmask (e.g., 192.168.0.1/32)</shortdesc> + <content type="string" default="" /> + </parameter> + <parameter name="alias_range_name" unique="0" required="0"> + <longdesc lang="en">Subnet name for the Alias IP</longdesc> + <shortdesc lang="en">Subnet name for the Alias IP</shortdesc> + <content type="string" default="" /> + </parameter> + <parameter name="hostlist" unique="0" required="0"> + <longdesc lang="en">List of hosts in the cluster, separated by spaces</longdesc> + <shortdesc lang="en">Host list</shortdesc> + <content type="string" default="" /> + </parameter> + <parameter name="project" unique="0" required="0"> + <longdesc lang="en"> + Project ID of the instance. It can be useful to set this + attribute if the instance is in a shared service project. + Otherwise, the agent should be able to determine the project ID + automatically. + </longdesc> + <shortdesc lang="en">Project ID</shortdesc> + <content type="string" default="default" /> + </parameter> + <parameter name="serviceaccount"> + <longdesc lang="en">Path to Service account JSON file</longdesc> + <shortdesc lang="en">Service account JSONfile</shortdesc> + <content type="string" default="" /> + </parameter> + <parameter name="stackdriver_logging" unique="0" required="0"> + <longdesc lang="en">If enabled (set to true), IP failover logs will be posted to stackdriver logging</longdesc> + <shortdesc lang="en">Stackdriver-logging support</shortdesc> + <content type="boolean" default="" /> + </parameter> + </parameters> + <actions> + <action name="start" timeout="300s" /> + <action name="stop" timeout="15s" /> + <action name="monitor" timeout="15s" interval="60s" depth="0" /> + <action name="meta-data" timeout="15s" /> + <action name="validate-all" timeout="15s" /> + </actions> +</resource-agent>''' + + +def get_metadata(metadata_key, params=None, timeout=None): + """Performs a GET request with the metadata headers. + + Args: + metadata_key: string, the metadata to perform a GET request on. + params: dictionary, the query parameters in the GET request. + timeout: int, timeout in seconds for metadata requests. + + Returns: + HTTP response from the GET request. + """ + for i in range(MAX_RETRIES): + try: + timeout = timeout or 60 + metadata_url = os.path.join(METADATA_SERVER, metadata_key) + params = urlparse.urlencode(params or {}) + url = '%s?%s' % (metadata_url, params) + request = urlrequest.Request(url, headers=METADATA_HEADERS) + request_opener = urlrequest.build_opener(urlrequest.ProxyHandler({})) + return request_opener.open( + request, timeout=timeout * 1.1).read().decode("utf-8") + except Exception as e: + logger.error('Couldn\'t get instance name, is this running inside GCE?: ' + + str(e)) + time.sleep(RETRY_BACKOFF_SECS * (i + 1)) + + # If the retries are exhausted we exit with a generic error. + sys.exit(OCF_ERR_GENERIC) + + +def create_api_connection(): + for i in range(MAX_RETRIES): + try: + serviceaccount = os.environ.get("OCF_RESKEY_serviceaccount") + if not serviceaccount: + try: + from googleapiclient import _auth + credentials = _auth.default_credentials(); + except: + credentials = GoogleCredentials.get_application_default() + logging.debug("using application default credentials") + else: + scope = ['https://www.googleapis.com/auth/cloud-platform'] + logging.debug("using credentials from service account") + try: + credentials = ServiceAccountCredentials.from_service_account_file(filename=serviceaccount, scopes=scope) + except AttributeError: + credentials = ServiceAccountCredentials.from_json_keyfile_name(serviceaccount, scope) + except Exception as e: + logging.error(str(e)) + sys.exit(OCF_ERR_GENERIC) + return googleapiclient.discovery.build('compute', 'v1', + credentials=credentials, + cache_discovery=False) + except Exception as e: + logger.error('Couldn\'t connect with google api: ' + str(e)) + time.sleep(RETRY_BACKOFF_SECS * (i + 1)) + + # If the retries are exhausted we exit with a generic error. + sys.exit(OCF_ERR_GENERIC) + + +def get_instance(project, zone, instance): + request = CONN.instances().get( + project=project, zone=zone, instance=instance) + return request.execute() + + +def get_network_ifaces(project, zone, instance): + return get_instance(project, zone, instance)['networkInterfaces'] + + +def wait_for_operation(project, zone, operation): + while True: + result = CONN.zoneOperations().get( + project=project, + zone=zone, + operation=operation['name']).execute() + + if result['status'] == 'DONE': + if 'error' in result: + raise Exception(result['error']) + return + time.sleep(1) + + +def set_aliases(project, zone, instance, aliases, fingerprint): + """Sets the alias IP ranges for an instance. + + Args: + project: string, the project in which the instance resides. + zone: string, the zone in which the instance resides. + instance: string, the name of the instance. + aliases: list, the list of dictionaries containing alias IP ranges + to be added to or removed from the instance. + fingerprint: string, the fingerprint of the network interface. + """ + body = { + 'aliasIpRanges': aliases, + 'fingerprint': fingerprint + } + + request = CONN.instances().updateNetworkInterface( + instance=instance, networkInterface='nic0', project=project, zone=zone, + body=body) + operation = request.execute() + wait_for_operation(project, zone, operation) + + +def add_rm_alias(mode, project, zone, instance, alias, alias_range_name=None): + """Adds or removes an alias IP range for a GCE instance. + + Args: + mode: int, a constant (ADD (0) or REMOVE (1)) indicating the + operation type. + project: string, the project in which the instance resides. + zone: string, the zone in which the instance resides. + instance: string, the name of the instance. + alias: string, the alias IP range to be added to or removed from + the instance. + alias_range_name: string, the subnet name for the alias IP range. + + Returns: + True if the existing list of alias IP ranges was modified, or False + otherwise. + """ + ifaces = get_network_ifaces(project, zone, instance) + fingerprint = ifaces[0]['fingerprint'] + + try: + old_aliases = ifaces[0]['aliasIpRanges'] + except KeyError: + old_aliases = [] + + new_aliases = [a for a in old_aliases if a['ipCidrRange'] != alias] + + if alias: + if mode == ADD: + obj = {'ipCidrRange': alias} + if alias_range_name: + obj['subnetworkRangeName'] = alias_range_name + new_aliases.append(obj) + elif mode == REMOVE: + pass # already removed during new_aliases build + else: + raise ValueError('Invalid value for mode: {}'.format(mode)) + + if (sorted(new_aliases, key=lambda item: item.get('ipCidrRange')) + != sorted(old_aliases, key=lambda item: item.get('ipCidrRange'))): + set_aliases(project, zone, instance, new_aliases, fingerprint) + return True + else: + return False + + +def add_alias(project, zone, instance, alias, alias_range_name=None): + return add_rm_alias(ADD, project, zone, instance, alias, alias_range_name) + + +def remove_alias(project, zone, instance, alias): + return add_rm_alias(REMOVE, project, zone, instance, alias) + + +def get_aliases(project, zone, instance): + ifaces = get_network_ifaces(project, zone, instance) + try: + aliases = ifaces[0]['aliasIpRanges'] + return [a['ipCidrRange'] for a in aliases] + except KeyError: + return [] + + +def get_localhost_aliases(): + net_iface = get_metadata('instance/network-interfaces', {'recursive': True}) + net_iface = json.loads(net_iface) + try: + return net_iface[0]['ipAliases'] + except (KeyError, IndexError): + return [] + + +def get_zone(project, instance): + fl = 'name="%s"' % instance + request = CONN.instances().aggregatedList(project=project, filter=fl) + while request is not None: + response = request.execute() + zones = response.get('items', {}) + for zone in zones.values(): + for inst in zone.get('instances', []): + if inst['name'] == instance: + return inst['zone'].split("/")[-1] + request = CONN.instances().aggregatedList_next( + previous_request=request, previous_response=response) + raise Exception("Unable to find instance %s" % (instance)) + + +def get_instances_list(project, exclude): + hostlist = [] + request = CONN.instances().aggregatedList(project=project) + while request is not None: + try: + response = request.execute() + zones = response.get('items', {}) + except googleapiclient.errors.HttpError as e: + if e.resp.status == 404: + logger.debug('get_instances_list(): no instances found') + return '' + + for zone in zones.values(): + for inst in zone.get('instances', []): + if inst['name'] != exclude: + hostlist.append(inst['name']) + request = CONN.instances().aggregatedList_next( + previous_request=request, previous_response=response) + return hostlist + + +def gcp_alias_start(alias): + my_aliases = get_localhost_aliases() + my_zone = get_metadata('instance/zone').split('/')[-1] + project = os.environ.get( + 'OCF_RESKEY_project', get_metadata('project/project-id')) + + if alias in my_aliases: + # TODO: Do we need to check alias_range_name? + logger.info( + '%s already has %s attached. No action required' % (THIS_VM, alias)) + sys.exit(OCF_SUCCESS) + + # If the alias is currently attached to another host, detach it. + hostlist = os.environ.get('OCF_RESKEY_hostlist', '') + if hostlist: + hostlist = hostlist.replace(THIS_VM, '').split() + else: + hostlist = get_instances_list(project, THIS_VM) + for host in hostlist: + host_zone = get_zone(project, host) + host_aliases = get_aliases(project, host_zone, host) + if alias in host_aliases: + logger.info( + '%s is attached to %s - Removing %s from %s' % + (alias, host, alias, host)) + remove_alias(project, host_zone, host, alias) + break + + # Add alias IP range to localhost + try: + add_alias( + project, my_zone, THIS_VM, alias, + os.environ.get('OCF_RESKEY_alias_range_name')) + except googleapiclient.errors.HttpError as e: + if e.resp.status == 404: + sys.exit(OCF_ERR_CONFIGURED) + + # Verify that the IP range has been added + my_aliases = get_localhost_aliases() + if alias in my_aliases: + logger.info('Finished adding %s to %s' % (alias, THIS_VM)) + else: + if my_aliases: + logger.error( + 'Failed to add alias IP range %s. %s has alias IP ranges attached but' + + ' they don\'t include %s' % (alias, THIS_VM, alias)) + else: + logger.error( + 'Failed to add IP range %s. %s has no alias IP ranges attached' + % (alias, THIS_VM)) + sys.exit(OCF_ERR_GENERIC) + + +def gcp_alias_stop(alias): + my_aliases = get_localhost_aliases() + my_zone = get_metadata('instance/zone').split('/')[-1] + project = os.environ.get( + 'OCF_RESKEY_project', get_metadata('project/project-id')) + + if alias in my_aliases: + logger.info('Removing %s from %s' % (alias, THIS_VM)) + remove_alias(project, my_zone, THIS_VM, alias) + else: + logger.info( + '%s is not attached to %s. No action required' + % (alias, THIS_VM)) + + +def gcp_alias_status(alias): + my_aliases = get_localhost_aliases() + if alias in my_aliases: + logger.info('%s has the correct IP range attached' % THIS_VM) + else: + sys.exit(OCF_NOT_RUNNING) + + +def validate(): + global ALIAS + global THIS_VM + global CONN + + CONN = create_api_connection() + THIS_VM = get_metadata('instance/name') + ALIAS = os.environ.get('OCF_RESKEY_alias_ip') + if not ALIAS: + logger.error('Missing alias_ip parameter') + sys.exit(OCF_ERR_CONFIGURED) + + +def configure_logs(): + # Prepare logging + global logger + logging.getLogger('googleapiclient').setLevel(logging.WARN) + logging_env = os.environ.get('OCF_RESKEY_stackdriver_logging') + if logging_env: + logging_env = logging_env.lower() + if any(x in logging_env for x in ['yes', 'true', 'enabled']): + try: + import google.cloud.logging.handlers + client = google.cloud.logging.Client() + handler = google.cloud.logging.handlers.CloudLoggingHandler( + client, name=THIS_VM) + handler.setLevel(logging.INFO) + formatter = logging.Formatter('gcp:alias "%(message)s"') + handler.setFormatter(formatter) + log.addHandler(handler) + logger = logging.LoggerAdapter(log, {'OCF_RESOURCE_INSTANCE': + OCF_RESOURCE_INSTANCE}) + except ImportError: + logger.error('Couldn\'t import google.cloud.logging, ' + 'disabling Stackdriver-logging support') + + +def main(): + if 'meta-data' in sys.argv[1]: + print(METADATA) + return + + validate() + if 'validate-all' in sys.argv[1]: + return + + configure_logs() + if 'start' in sys.argv[1]: + gcp_alias_start(ALIAS) + elif 'stop' in sys.argv[1]: + gcp_alias_stop(ALIAS) + elif 'status' in sys.argv[1] or 'monitor' in sys.argv[1]: + gcp_alias_status(ALIAS) + else: + logger.error('no such function %s' % str(sys.argv[1])) + + +if __name__ == "__main__": + main() |