1#!/usr/bin/python 2# -*- coding: utf-8 -*- 3# 4# Copyright: (c) 2018, F5 Networks Inc. 5# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 7from __future__ import absolute_import, division, print_function 8__metaclass__ = type 9 10 11ANSIBLE_METADATA = {'metadata_version': '1.1', 12 'status': ['preview'], 13 'supported_by': 'certified'} 14 15DOCUMENTATION = r''' 16--- 17module: bigip_software_install 18short_description: Install software images on a BIG-IP 19description: 20 - Install new images on a BIG-IP. 21version_added: 2.7 22options: 23 image: 24 description: 25 - Image to install on the remote device. 26 type: str 27 volume: 28 description: 29 - The volume to install the software image to. 30 type: str 31 state: 32 description: 33 - When C(installed), ensures that the software is installed on the volume 34 and the volume is set to be booted from. The device is B(not) rebooted 35 into the new software. 36 - When C(activated), performs the same operation as C(installed), but 37 the system is rebooted to the new software. 38 type: str 39 choices: 40 - activated 41 - installed 42 default: activated 43extends_documentation_fragment: f5 44author: 45 - Tim Rupp (@caphrim007) 46 - Wojciech Wypior (@wojtek0806) 47''' 48EXAMPLES = r''' 49- name: Ensure an existing image is installed in specified volume 50 bigip_software_install: 51 image: BIGIP-13.0.0.0.0.1645.iso 52 volume: HD1.2 53 state: installed 54 provider: 55 password: secret 56 server: lb.mydomain.com 57 user: admin 58 delegate_to: localhost 59 60- name: Ensure an existing image is activated in specified volume 61 bigip_software_install: 62 image: BIGIP-13.0.0.0.0.1645.iso 63 state: activated 64 volume: HD1.2 65 provider: 66 password: secret 67 server: lb.mydomain.com 68 user: admin 69 delegate_to: localhost 70''' 71 72RETURN = r''' 73# only common fields returned 74''' 75 76import time 77import ssl 78 79from ansible.module_utils.six.moves.urllib.error import URLError 80from ansible.module_utils.urls import urlparse 81from ansible.module_utils.basic import AnsibleModule 82 83try: 84 from library.module_utils.network.f5.bigip import F5RestClient 85 from library.module_utils.network.f5.common import F5ModuleError 86 from library.module_utils.network.f5.common import AnsibleF5Parameters 87 from library.module_utils.network.f5.common import f5_argument_spec 88except ImportError: 89 from ansible.module_utils.network.f5.bigip import F5RestClient 90 from ansible.module_utils.network.f5.common import F5ModuleError 91 from ansible.module_utils.network.f5.common import AnsibleF5Parameters 92 from ansible.module_utils.network.f5.common import f5_argument_spec 93 94 95class Parameters(AnsibleF5Parameters): 96 api_map = { 97 98 } 99 100 api_attributes = [ 101 'options', 102 'volume', 103 ] 104 105 returnables = [ 106 107 ] 108 109 updatables = [ 110 111 ] 112 113 114class ApiParameters(Parameters): 115 @property 116 def image_names(self): 117 result = [] 118 result += self.read_image_from_device('image') 119 result += self.read_image_from_device('hotfix') 120 return result 121 122 def read_image_from_device(self, t): 123 uri = "https://{0}:{1}/mgmt/tm/sys/software/{2}".format( 124 self.client.provider['server'], 125 self.client.provider['server_port'], 126 t, 127 ) 128 resp = self.client.api.get(uri) 129 try: 130 response = resp.json() 131 except ValueError: 132 return [] 133 134 if 'code' in response and response['code'] == 400: 135 if 'message' in response: 136 return [] 137 else: 138 return [] 139 if 'items' not in response: 140 return [] 141 return [x['name'].split('/')[0] for x in response['items']] 142 143 144class ModuleParameters(Parameters): 145 @property 146 def version(self): 147 if self._values['version']: 148 return self._values['version'] 149 150 self._values['version'] = self.image_info['version'] 151 return self._values['version'] 152 153 @property 154 def build(self): 155 # Return cached copy if we have it 156 if self._values['build']: 157 return self._values['build'] 158 159 # Otherwise, get copy from image info cache 160 self._values['build'] = self.image_info['build'] 161 return self._values['build'] 162 163 @property 164 def image_info(self): 165 if self._values['image_info']: 166 image = self._values['image_info'] 167 else: 168 # Otherwise, get a new copy and store in cache 169 image = self.read_image() 170 self._values['image_info'] = image 171 return image 172 173 @property 174 def image_type(self): 175 if self._values['image_type']: 176 return self._values['image_type'] 177 if 'software:image' in self.image_info['kind']: 178 self._values['image_type'] = 'image' 179 else: 180 self._values['image_type'] = 'hotfix' 181 return self._values['image_type'] 182 183 def read_image(self): 184 image = self.read_image_from_device(type='image') 185 if image: 186 return image 187 image = self.read_image_from_device(type='hotfix') 188 if image: 189 return image 190 return None 191 192 def read_image_from_device(self, type): 193 uri = "https://{0}:{1}/mgmt/tm/sys/software/{2}/".format( 194 self.client.provider['server'], 195 self.client.provider['server_port'], 196 type, 197 ) 198 resp = self.client.api.get(uri) 199 200 try: 201 response = resp.json() 202 except ValueError as ex: 203 raise F5ModuleError(str(ex)) 204 205 if 'items' in response: 206 for item in response['items']: 207 if item['name'].startswith(self.image): 208 return item 209 210 211class Changes(Parameters): 212 def to_return(self): 213 result = {} 214 try: 215 for returnable in self.returnables: 216 result[returnable] = getattr(self, returnable) 217 result = self._filter_params(result) 218 except Exception: 219 pass 220 return result 221 222 223class UsableChanges(Changes): 224 pass 225 226 227class ReportableChanges(Changes): 228 pass 229 230 231class Difference(object): 232 def __init__(self, want, have=None): 233 self.want = want 234 self.have = have 235 236 def compare(self, param): 237 try: 238 result = getattr(self, param) 239 return result 240 except AttributeError: 241 return self.__default(param) 242 243 def __default(self, param): 244 attr1 = getattr(self.want, param) 245 try: 246 attr2 = getattr(self.have, param) 247 if attr1 != attr2: 248 return attr1 249 except AttributeError: 250 return attr1 251 252 253class ModuleManager(object): 254 def __init__(self, *args, **kwargs): 255 self.module = kwargs.get('module', None) 256 self.client = F5RestClient(**self.module.params) 257 self.want = ModuleParameters(params=self.module.params, client=self.client) 258 self.have = ApiParameters(client=self.client) 259 self.changes = UsableChanges() 260 self.volume_url = None 261 262 def _set_changed_options(self): 263 changed = {} 264 for key in Parameters.returnables: 265 if getattr(self.want, key) is not None: 266 changed[key] = getattr(self.want, key) 267 if changed: 268 self.changes = UsableChanges(params=changed) 269 270 def _update_changed_options(self): 271 diff = Difference(self.want, self.have) 272 updatables = Parameters.updatables 273 changed = dict() 274 for k in updatables: 275 change = diff.compare(k) 276 if change is None: 277 continue 278 else: 279 if isinstance(change, dict): 280 changed.update(change) 281 else: 282 changed[k] = change 283 if changed: 284 self.changes = UsableChanges(params=changed) 285 return True 286 return False 287 288 def should_update(self): 289 result = self._update_changed_options() 290 if result: 291 return True 292 return False 293 294 def exec_module(self): 295 result = dict() 296 297 changed = self.present() 298 299 reportable = ReportableChanges(params=self.changes.to_return()) 300 changes = reportable.to_return() 301 result.update(**changes) 302 result.update(dict(changed=changed)) 303 self._announce_deprecations(result) 304 return result 305 306 def _announce_deprecations(self, result): 307 warnings = result.pop('__warnings', []) 308 for warning in warnings: 309 self.client.module.deprecate( 310 msg=warning['msg'], 311 version=warning['version'] 312 ) 313 314 def present(self): 315 if self.exists(): 316 return False 317 else: 318 return self.update() 319 320 def _set_volume_url(self, item): 321 path = urlparse(item['selfLink']).path 322 self.volume_url = "https://{0}:{1}{2}".format( 323 self.client.provider['server'], 324 self.client.provider['server_port'], 325 path 326 ) 327 328 def exists(self): 329 uri = "https://{0}:{1}/mgmt/tm/sys/software/volume/".format( 330 self.client.provider['server'], 331 self.client.provider['server_port'] 332 ) 333 resp = self.client.api.get(uri) 334 335 try: 336 collection = resp.json() 337 except ValueError: 338 return False 339 340 for item in collection['items']: 341 if item['name'].startswith(self.want.volume): 342 self._set_volume_url(item) 343 break 344 345 if not self.volume_url: 346 self.volume_url = uri + self.want.volume 347 348 resp = self.client.api.get(self.volume_url) 349 350 try: 351 response = resp.json() 352 except ValueError: 353 return False 354 355 if resp.status == 404 or 'code' in response and response['code'] == 404: 356 return False 357 358 # version key can be missing in the event that an existing volume has 359 # no installed software in it. 360 if self.want.version != response.get('version', None): 361 return False 362 if self.want.build != response.get('build', None): 363 return False 364 365 if self.want.state == 'installed': 366 return True 367 if self.want.state == 'activated': 368 if 'defaultBootLocation' in response['media'][0]: 369 return True 370 return False 371 372 def volume_exists(self): 373 resp = self.client.api.get(self.volume_url) 374 375 try: 376 response = resp.json() 377 except ValueError: 378 return False 379 if resp.status == 404 or 'code' in response and response['code'] == 404: 380 return False 381 return True 382 383 def update(self): 384 if self.module.check_mode: 385 return True 386 387 if self.want.image and self.want.image not in self.have.image_names: 388 raise F5ModuleError( 389 "The specified image was not found on the device." 390 ) 391 392 options = list() 393 if not self.volume_exists(): 394 options.append({'create-volume': True}) 395 if self.want.state == 'activated': 396 options.append({'reboot': True}) 397 self.want.update({'options': options}) 398 399 self.update_on_device() 400 self.wait_for_software_install_on_device() 401 if self.want.state == 'activated': 402 self.wait_for_device_reboot() 403 return True 404 405 def update_on_device(self): 406 params = { 407 "command": "install", 408 "name": self.want.image, 409 } 410 params.update(self.want.api_params()) 411 412 uri = "https://{0}:{1}/mgmt/tm/sys/software/{2}".format( 413 self.client.provider['server'], 414 self.client.provider['server_port'], 415 self.want.image_type 416 ) 417 resp = self.client.api.post(uri, json=params) 418 try: 419 response = resp.json() 420 if 'commandResult' in response and len(response['commandResult'].strip()) > 0: 421 raise F5ModuleError(response['commandResult']) 422 except ValueError as ex: 423 raise F5ModuleError(str(ex)) 424 if 'code' in response and response['code'] in [400, 403]: 425 if 'message' in response: 426 raise F5ModuleError(response['message']) 427 else: 428 raise F5ModuleError(resp.content) 429 return True 430 431 def wait_for_device_reboot(self): 432 while True: 433 time.sleep(5) 434 try: 435 self.client.reconnect() 436 volume = self.read_volume_from_device() 437 if volume is None: 438 continue 439 if 'active' in volume and volume['active'] is True: 440 break 441 except F5ModuleError: 442 # Handle all exceptions because if the system is offline (for a 443 # reboot) the REST client will raise exceptions about 444 # connections 445 pass 446 447 def wait_for_software_install_on_device(self): 448 # We need to delay this slightly in case the the volume needs to be 449 # created first 450 for dummy in range(10): 451 try: 452 if self.volume_exists(): 453 break 454 except F5ModuleError: 455 pass 456 time.sleep(5) 457 while True: 458 time.sleep(10) 459 volume = self.read_volume_from_device() 460 if volume is None or 'status' not in volume: 461 self.client.reconnect() 462 continue 463 if volume['status'] == 'complete': 464 break 465 elif volume['status'] == 'failed': 466 raise F5ModuleError 467 468 def read_volume_from_device(self): 469 try: 470 resp = self.client.api.get(self.volume_url) 471 response = resp.json() 472 except ValueError as ex: 473 raise F5ModuleError(str(ex)) 474 except ssl.SSLError: 475 # Suggests BIG-IP is still in the middle of restarting itself or 476 # restjavad is restarting. 477 return None 478 except URLError: 479 # At times during reboot BIG-IP will reset or timeout connections so we catch and pass this here. 480 return None 481 482 if 'code' in response and response['code'] == 400: 483 if 'message' in response: 484 raise F5ModuleError(response['message']) 485 else: 486 raise F5ModuleError(resp.content) 487 return response 488 489 490class ArgumentSpec(object): 491 def __init__(self): 492 self.supports_check_mode = True 493 argument_spec = dict( 494 image=dict(), 495 volume=dict(), 496 state=dict( 497 default='activated', 498 choices=['activated', 'installed'] 499 ), 500 ) 501 self.argument_spec = {} 502 self.argument_spec.update(f5_argument_spec) 503 self.argument_spec.update(argument_spec) 504 505 506def main(): 507 spec = ArgumentSpec() 508 509 module = AnsibleModule( 510 argument_spec=spec.argument_spec, 511 supports_check_mode=spec.supports_check_mode, 512 ) 513 514 try: 515 mm = ModuleManager(module=module) 516 results = mm.exec_module() 517 module.exit_json(**results) 518 except F5ModuleError as ex: 519 module.fail_json(msg=str(ex)) 520 521 522if __name__ == '__main__': 523 main() 524