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