1#!@PYTHON@ -tt
2# - *- coding: utf- 8 - *-
3#
4# ---------------------------------------------------------------------
5# Copyright 2018 Google Inc.
6#
7# Licensed under the Apache License, Version 2.0 (the "License");
8# you may not use this file except in compliance with the License.
9# You may obtain a copy of the License at
10#
11# http://www.apache.org/licenses/LICENSE-2.0
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17# ---------------------------------------------------------------------
18# Description:	Google Cloud Platform - Disk attach
19# ---------------------------------------------------------------------
20
21import json
22import logging
23import os
24import re
25import sys
26import time
27
28OCF_FUNCTIONS_DIR = os.environ.get("OCF_FUNCTIONS_DIR", "%s/lib/heartbeat" % os.environ.get("OCF_ROOT"))
29sys.path.append(OCF_FUNCTIONS_DIR)
30
31import ocf
32from ocf import logger
33
34try:
35  import googleapiclient.discovery
36except ImportError:
37  pass
38
39if sys.version_info >= (3, 0):
40  # Python 3 imports.
41  import urllib.parse as urlparse
42  import urllib.request as urlrequest
43else:
44  # Python 2 imports.
45  import urllib as urlparse
46  import urllib2 as urlrequest
47
48
49CONN = None
50PROJECT = None
51ZONE = None
52REGION = None
53LIST_DISK_ATTACHED_INSTANCES = None
54INSTANCE_NAME = None
55
56PARAMETERS = {
57  'disk_name': '',
58  'disk_scope': 'detect',
59  'disk_csek_file': '',
60  'mode': "READ_WRITE",
61  'device_name': '',
62  'stackdriver_logging': 'no',
63}
64
65MANDATORY_PARAMETERS = ['disk_name', 'disk_scope']
66
67METADATA_SERVER = 'http://metadata.google.internal/computeMetadata/v1/'
68METADATA_HEADERS = {'Metadata-Flavor': 'Google'}
69METADATA = '''<?xml version="1.0"?>
70<!DOCTYPE resource-agent SYSTEM "ra-api-1.dtd">
71<resource-agent name="gcp-pd-move">
72<version>1.0</version>
73<longdesc lang="en">
74Resource Agent that can attach or detach a regional/zonal disk on current GCP
75instance.
76Requirements :
77- Disk has to be properly created as regional/zonal in order to be used
78correctly.
79</longdesc>
80<shortdesc lang="en">Attach/Detach a persistent disk on current GCP instance</shortdesc>
81<parameters>
82<parameter name="disk_name" unique="1" required="1">
83<longdesc lang="en">The name of the GCP disk.</longdesc>
84<shortdesc lang="en">Disk name</shortdesc>
85<content type="string" default="{}" />
86</parameter>
87<parameter name="disk_scope">
88<longdesc lang="en">Disk scope</longdesc>
89<shortdesc lang="en">Network name</shortdesc>
90<content type="string" default="{}" />
91</parameter>
92<parameter name="disk_csek_file">
93<longdesc lang="en">Path to a Customer-Supplied Encryption Key (CSEK) key file</longdesc>
94<shortdesc lang="en">Customer-Supplied Encryption Key file</shortdesc>
95<content type="string" default="{}" />
96</parameter>
97<parameter name="mode">
98<longdesc lang="en">Attachment mode (READ_WRITE, READ_ONLY)</longdesc>
99<shortdesc lang="en">Attachment mode</shortdesc>
100<content type="string" default="{}" />
101</parameter>
102<parameter name="device_name">
103<longdesc lang="en">An optional name that indicates the disk name the guest operating system will see.</longdesc>
104<shortdesc lang="en">Optional device name</shortdesc>
105<content type="boolean" default="{}" />
106</parameter>
107<parameter name="stackdriver_logging">
108<longdesc lang="en">Use stackdriver_logging output to global resource (yes, true, enabled)</longdesc>
109<shortdesc lang="en">Use stackdriver_logging</shortdesc>
110<content type="string" default="{}" />
111</parameter>
112</parameters>
113<actions>
114<action name="start" timeout="300s" />
115<action name="stop" timeout="15s" />
116<action name="monitor" timeout="15s" interval="10s" depth="0" />
117<action name="meta-data" timeout="5s" />
118</actions>
119</resource-agent>'''.format(PARAMETERS['disk_name'], PARAMETERS['disk_scope'],
120  PARAMETERS['disk_csek_file'], PARAMETERS['mode'], PARAMETERS['device_name'],
121  PARAMETERS['stackdriver_logging'])
122
123
124def get_metadata(metadata_key, params=None, timeout=None):
125  """Performs a GET request with the metadata headers.
126
127  Args:
128    metadata_key: string, the metadata to perform a GET request on.
129    params: dictionary, the query parameters in the GET request.
130    timeout: int, timeout in seconds for metadata requests.
131
132  Returns:
133    HTTP response from the GET request.
134
135  Raises:
136    urlerror.HTTPError: raises when the GET request fails.
137  """
138  timeout = timeout or 60
139  metadata_url = os.path.join(METADATA_SERVER, metadata_key)
140  params = urlparse.urlencode(params or {})
141  url = '%s?%s' % (metadata_url, params)
142  request = urlrequest.Request(url, headers=METADATA_HEADERS)
143  request_opener = urlrequest.build_opener(urlrequest.ProxyHandler({}))
144  return request_opener.open(request, timeout=timeout * 1.1).read().decode("utf-8")
145
146
147def populate_vars():
148  global CONN
149  global INSTANCE_NAME
150  global PROJECT
151  global ZONE
152  global REGION
153  global LIST_DISK_ATTACHED_INSTANCES
154
155  # Populate global vars
156  try:
157    CONN = googleapiclient.discovery.build('compute', 'v1')
158  except Exception as e:
159    logger.error('Couldn\'t connect with google api: ' + str(e))
160    sys.exit(ocf.OCF_ERR_CONFIGURED)
161
162  for param in PARAMETERS:
163    value = os.environ.get('OCF_RESKEY_%s' % param, PARAMETERS[param])
164    if not value and param in MANDATORY_PARAMETERS:
165      logger.error('Missing %s mandatory parameter' % param)
166      sys.exit(ocf.OCF_ERR_CONFIGURED)
167    elif value:
168      PARAMETERS[param] = value
169
170  try:
171    INSTANCE_NAME = get_metadata('instance/name')
172  except Exception as e:
173    logger.error(
174        'Couldn\'t get instance name, is this running inside GCE?: ' + str(e))
175    sys.exit(ocf.OCF_ERR_CONFIGURED)
176
177  PROJECT = get_metadata('project/project-id')
178  if PARAMETERS['disk_scope'] in ['detect', 'regional']:
179    ZONE = get_metadata('instance/zone').split('/')[-1]
180    REGION = ZONE[:-2]
181  else:
182    ZONE = PARAMETERS['disk_scope']
183  LIST_DISK_ATTACHED_INSTANCES = get_disk_attached_instances(
184      PARAMETERS['disk_name'])
185
186
187def configure_logs():
188  # Prepare logging
189  global logger
190  logging.getLogger('googleapiclient').setLevel(logging.WARN)
191  logging_env = os.environ.get('OCF_RESKEY_stackdriver_logging')
192  if logging_env:
193    logging_env = logging_env.lower()
194    if any(x in logging_env for x in ['yes', 'true', 'enabled']):
195      try:
196        import google.cloud.logging.handlers
197        client = google.cloud.logging.Client()
198        handler = google.cloud.logging.handlers.CloudLoggingHandler(
199            client, name=INSTANCE_NAME)
200        handler.setLevel(logging.INFO)
201        formatter = logging.Formatter('gcp:alias "%(message)s"')
202        handler.setFormatter(formatter)
203        ocf.log.addHandler(handler)
204        logger = logging.LoggerAdapter(
205            ocf.log, {'OCF_RESOURCE_INSTANCE': ocf.OCF_RESOURCE_INSTANCE})
206      except ImportError:
207        logger.error('Couldn\'t import google.cloud.logging, '
208            'disabling Stackdriver-logging support')
209
210
211def wait_for_operation(operation):
212  while True:
213    result = CONN.zoneOperations().get(
214        project=PROJECT,
215        zone=ZONE,
216        operation=operation['name']).execute()
217
218    if result['status'] == 'DONE':
219      if 'error' in result:
220        raise Exception(result['error'])
221      return
222    time.sleep(1)
223
224
225def get_disk_attached_instances(disk):
226  def get_users_list():
227    fl = 'name="%s"' % disk
228    request = CONN.disks().aggregatedList(project=PROJECT, filter=fl)
229    while request is not None:
230      response = request.execute()
231      locations = response.get('items', {})
232      for location in locations.values():
233        for d in location.get('disks', []):
234          if d['name'] == disk:
235            return d.get('users', [])
236      request = CONN.instances().aggregatedList_next(
237          previous_request=request, previous_response=response)
238    raise Exception("Unable to find disk %s" % disk)
239
240  def get_only_instance_name(user):
241    return re.sub('.*/instances/', '', user)
242
243  return map(get_only_instance_name, get_users_list())
244
245
246def is_disk_attached(instance):
247  return instance in LIST_DISK_ATTACHED_INSTANCES
248
249
250def detach_disk(instance, disk_name):
251  # Python API misses disk-scope argument.
252
253  # Detaching a disk is only possible by using deviceName, which is retrieved
254  # as a disk parameter when listing the instance information
255  request = CONN.instances().get(
256      project=PROJECT, zone=ZONE, instance=instance)
257  response = request.execute()
258
259  device_name = None
260  for disk in response['disks']:
261    if disk_name == re.sub('.*disks/',"",disk['source']):
262      device_name = disk['deviceName']
263      break
264
265  if not device_name:
266    logger.error("Didn't find %(d)s deviceName attached to %(i)s" % {
267        'd': disk_name,
268        'i': instance,
269    })
270    return
271
272  request = CONN.instances().detachDisk(
273      project=PROJECT, zone=ZONE, instance=instance, deviceName=device_name)
274  wait_for_operation(request.execute())
275
276
277def attach_disk(instance, disk_name):
278  location = 'zones/%s' % ZONE
279  if PARAMETERS['disk_scope'] == 'regional':
280    location = 'regions/%s' % REGION
281
282  prefix = 'https://www.googleapis.com/compute/v1'
283  body = {
284    'source': '%(prefix)s/projects/%(project)s/%(location)s/disks/%(disk)s' % {
285        'prefix': prefix,
286        'project': PROJECT,
287        'location': location,
288        'disk': disk_name,
289    },
290  }
291
292  # Customer-Supplied Encryption Key (CSEK)
293  if PARAMETERS['disk_csek_file']:
294    with open(PARAMETERS['disk_csek_file']) as csek_file:
295      body['diskEncryptionKey'] = {
296          'rawKey': csek_file.read(),
297      }
298
299  if PARAMETERS['device_name']:
300    body['deviceName'] = PARAMETERS['device_name']
301
302  if PARAMETERS['mode']:
303    body['mode'] = PARAMETERS['mode']
304
305  force_attach = None
306  if PARAMETERS['disk_scope'] == 'regional':
307    # Python API misses disk-scope argument.
308    force_attach = True
309  else:
310    # If this disk is attached to some instance, detach it first.
311    for other_instance in LIST_DISK_ATTACHED_INSTANCES:
312      logger.info("Detaching disk %(disk_name)s from other instance %(i)s" % {
313          'disk_name': PARAMETERS['disk_name'],
314          'i': other_instance,
315      })
316      detach_disk(other_instance, PARAMETERS['disk_name'])
317
318  request = CONN.instances().attachDisk(
319      project=PROJECT, zone=ZONE, instance=instance, body=body,
320      forceAttach=force_attach)
321  wait_for_operation(request.execute())
322
323
324def fetch_data():
325  configure_logs()
326  populate_vars()
327
328
329def gcp_pd_move_start():
330  fetch_data()
331  if not is_disk_attached(INSTANCE_NAME):
332    logger.info("Attaching disk %(disk_name)s to %(instance)s" % {
333        'disk_name': PARAMETERS['disk_name'],
334        'instance': INSTANCE_NAME,
335    })
336    attach_disk(INSTANCE_NAME, PARAMETERS['disk_name'])
337
338
339def gcp_pd_move_stop():
340  fetch_data()
341  if is_disk_attached(INSTANCE_NAME):
342    logger.info("Detaching disk %(disk_name)s to %(instance)s" % {
343        'disk_name': PARAMETERS['disk_name'],
344        'instance': INSTANCE_NAME,
345    })
346    detach_disk(INSTANCE_NAME, PARAMETERS['disk_name'])
347
348
349def gcp_pd_move_status():
350  fetch_data()
351  if is_disk_attached(INSTANCE_NAME):
352    logger.debug("Disk %(disk_name)s is correctly attached to %(instance)s" % {
353        'disk_name': PARAMETERS['disk_name'],
354        'instance': INSTANCE_NAME,
355    })
356  else:
357    sys.exit(ocf.OCF_NOT_RUNNING)
358
359
360def main():
361  if len(sys.argv) < 2:
362    logger.error('Missing argument')
363    return
364
365  command = sys.argv[1]
366  if 'meta-data' in command:
367    print(METADATA)
368    return
369
370  if command in 'start':
371    gcp_pd_move_start()
372  elif command in 'stop':
373    gcp_pd_move_stop()
374  elif command in ('monitor', 'status'):
375    gcp_pd_move_status()
376  else:
377    configure_logs()
378    logger.error('no such function %s' % str(command))
379
380
381if __name__ == "__main__":
382  main()
383