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