1#!/usr/bin/python
2# -*- coding: utf-8 -*-
3
4# Copyright: (c) 2016, Adam Števko <adam.stevko@gmail.com>
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': 'community'}
14
15
16DOCUMENTATION = r'''
17---
18module: beadm
19short_description: Manage ZFS boot environments on FreeBSD/Solaris/illumos systems.
20description:
21    - Create, delete or activate ZFS boot environments.
22    - Mount and unmount ZFS boot environments.
23version_added: "2.3"
24author: Adam Števko (@xen0l)
25options:
26    name:
27        description:
28            - ZFS boot environment name.
29        type: str
30        required: True
31        aliases: [ "be" ]
32    snapshot:
33        description:
34            - If specified, the new boot environment will be cloned from the given
35              snapshot or inactive boot environment.
36        type: str
37    description:
38        description:
39            - Associate a description with a new boot environment. This option is
40              available only on Solarish platforms.
41        type: str
42    options:
43        description:
44            - Create the datasets for new BE with specific ZFS properties.
45            - Multiple options can be specified.
46            - This option is available only on Solarish platforms.
47        type: str
48    mountpoint:
49        description:
50            - Path where to mount the ZFS boot environment.
51        type: path
52    state:
53        description:
54            - Create or delete ZFS boot environment.
55        type: str
56        choices: [ absent, activated, mounted, present, unmounted ]
57        default: present
58    force:
59        description:
60            - Specifies if the unmount should be forced.
61        type: bool
62        default: false
63'''
64
65EXAMPLES = r'''
66- name: Create ZFS boot environment
67  beadm:
68    name: upgrade-be
69    state: present
70
71- name: Create ZFS boot environment from existing inactive boot environment
72  beadm:
73    name: upgrade-be
74    snapshot: be@old
75    state: present
76
77- name: Create ZFS boot environment with compression enabled and description "upgrade"
78  beadm:
79    name: upgrade-be
80    options: "compression=on"
81    description: upgrade
82    state: present
83
84- name: Delete ZFS boot environment
85  beadm:
86    name: old-be
87    state: absent
88
89- name: Mount ZFS boot environment on /tmp/be
90  beadm:
91    name: BE
92    mountpoint: /tmp/be
93    state: mounted
94
95- name: Unmount ZFS boot environment
96  beadm:
97    name: BE
98    state: unmounted
99
100- name: Activate ZFS boot environment
101  beadm:
102    name: upgrade-be
103    state: activated
104'''
105
106RETURN = r'''
107name:
108    description: BE name
109    returned: always
110    type: str
111    sample: pre-upgrade
112snapshot:
113    description: ZFS snapshot to create BE from
114    returned: always
115    type: str
116    sample: rpool/ROOT/oi-hipster@fresh
117description:
118    description: BE description
119    returned: always
120    type: str
121    sample: Upgrade from 9.0 to 10.0
122options:
123    description: BE additional options
124    returned: always
125    type: str
126    sample: compression=on
127mountpoint:
128    description: BE mountpoint
129    returned: always
130    type: str
131    sample: /mnt/be
132state:
133    description: state of the target
134    returned: always
135    type: str
136    sample: present
137force:
138    description: If forced action is wanted
139    returned: always
140    type: bool
141    sample: False
142'''
143
144import os
145import re
146from ansible.module_utils.basic import AnsibleModule
147
148
149class BE(object):
150    def __init__(self, module):
151        self.module = module
152
153        self.name = module.params['name']
154        self.snapshot = module.params['snapshot']
155        self.description = module.params['description']
156        self.options = module.params['options']
157        self.mountpoint = module.params['mountpoint']
158        self.state = module.params['state']
159        self.force = module.params['force']
160        self.is_freebsd = os.uname()[0] == 'FreeBSD'
161
162    def _beadm_list(self):
163        cmd = [self.module.get_bin_path('beadm')]
164        cmd.append('list')
165        cmd.append('-H')
166        if '@' in self.name:
167            cmd.append('-s')
168        return self.module.run_command(cmd)
169
170    def _find_be_by_name(self, out):
171        if '@' in self.name:
172            for line in out.splitlines():
173                if self.is_freebsd:
174                    check = re.match(r'.+/({0})\s+\-'.format(self.name), line)
175                    if check:
176                        return check
177                else:
178                    check = line.split(';')
179                    if check[1] == self.name:
180                        return check
181        else:
182            splitter = '\t' if self.is_freebsd else ';'
183            for line in out.splitlines():
184                check = line.split(splitter)
185                if check[0] == self.name:
186                    return check
187
188        return None
189
190    def exists(self):
191        (rc, out, _) = self._beadm_list()
192
193        if rc == 0:
194            if self._find_be_by_name(out):
195                return True
196            else:
197                return False
198        else:
199            return False
200
201    def is_activated(self):
202        (rc, out, _) = self._beadm_list()
203
204        if rc == 0:
205            line = self._find_be_by_name(out)
206            if self.is_freebsd:
207                if line is not None and 'R' in line.split('\t')[1]:
208                    return True
209            else:
210                if 'R' in line.split(';')[2]:
211                    return True
212
213        return False
214
215    def activate_be(self):
216        cmd = [self.module.get_bin_path('beadm')]
217
218        cmd.append('activate')
219        cmd.append(self.name)
220
221        return self.module.run_command(cmd)
222
223    def create_be(self):
224        cmd = [self.module.get_bin_path('beadm')]
225
226        cmd.append('create')
227
228        if self.snapshot:
229            cmd.append('-e')
230            cmd.append(self.snapshot)
231
232        if not self.is_freebsd:
233            if self.description:
234                cmd.append('-d')
235                cmd.append(self.description)
236
237            if self.options:
238                cmd.append('-o')
239                cmd.append(self.options)
240
241        cmd.append(self.name)
242
243        return self.module.run_command(cmd)
244
245    def destroy_be(self):
246        cmd = [self.module.get_bin_path('beadm')]
247
248        cmd.append('destroy')
249        cmd.append('-F')
250        cmd.append(self.name)
251
252        return self.module.run_command(cmd)
253
254    def is_mounted(self):
255        (rc, out, _) = self._beadm_list()
256
257        if rc == 0:
258            line = self._find_be_by_name(out)
259            if self.is_freebsd:
260                # On FreeBSD, we exclude currently mounted BE on /, as it is
261                # special and can be activated even if it is mounted. That is not
262                # possible with non-root BEs.
263                if line.split('\t')[2] != '-' and \
264                        line.split('\t')[2] != '/':
265                    return True
266            else:
267                if line.split(';')[3]:
268                    return True
269
270        return False
271
272    def mount_be(self):
273        cmd = [self.module.get_bin_path('beadm')]
274
275        cmd.append('mount')
276        cmd.append(self.name)
277
278        if self.mountpoint:
279            cmd.append(self.mountpoint)
280
281        return self.module.run_command(cmd)
282
283    def unmount_be(self):
284        cmd = [self.module.get_bin_path('beadm')]
285
286        cmd.append('unmount')
287        if self.force:
288            cmd.append('-f')
289        cmd.append(self.name)
290
291        return self.module.run_command(cmd)
292
293
294def main():
295    module = AnsibleModule(
296        argument_spec=dict(
297            name=dict(type='str', required=True, aliases=['be']),
298            snapshot=dict(type='str'),
299            description=dict(type='str'),
300            options=dict(type='str'),
301            mountpoint=dict(type='path'),
302            state=dict(type='str', default='present', choices=['absent', 'activated', 'mounted', 'present', 'unmounted']),
303            force=dict(type='bool', default=False),
304        ),
305        supports_check_mode=True,
306    )
307
308    be = BE(module)
309
310    rc = None
311    out = ''
312    err = ''
313    result = {}
314    result['name'] = be.name
315    result['state'] = be.state
316
317    if be.snapshot:
318        result['snapshot'] = be.snapshot
319
320    if be.description:
321        result['description'] = be.description
322
323    if be.options:
324        result['options'] = be.options
325
326    if be.mountpoint:
327        result['mountpoint'] = be.mountpoint
328
329    if be.state == 'absent':
330        # beadm on FreeBSD and Solarish systems differs in delete behaviour in
331        # that we are not allowed to delete activated BE on FreeBSD while on
332        # Solarish systems we cannot delete BE if it is mounted. We add mount
333        # check for both platforms as BE should be explicitly unmounted before
334        # being deleted. On FreeBSD, we also check if the BE is activated.
335        if be.exists():
336            if not be.is_mounted():
337                if module.check_mode:
338                    module.exit_json(changed=True)
339
340                if be.is_freebsd:
341                    if be.is_activated():
342                        module.fail_json(msg='Unable to remove active BE!')
343
344                (rc, out, err) = be.destroy_be()
345
346                if rc != 0:
347                    module.fail_json(msg='Error while destroying BE: "%s"' % err,
348                                     name=be.name,
349                                     stderr=err,
350                                     rc=rc)
351            else:
352                module.fail_json(msg='Unable to remove BE as it is mounted!')
353
354    elif be.state == 'present':
355        if not be.exists():
356            if module.check_mode:
357                module.exit_json(changed=True)
358
359            (rc, out, err) = be.create_be()
360
361            if rc != 0:
362                module.fail_json(msg='Error while creating BE: "%s"' % err,
363                                 name=be.name,
364                                 stderr=err,
365                                 rc=rc)
366
367    elif be.state == 'activated':
368        if not be.is_activated():
369            if module.check_mode:
370                module.exit_json(changed=True)
371
372            # On FreeBSD, beadm is unable to activate mounted BEs, so we add
373            # an explicit check for that case.
374            if be.is_freebsd:
375                if be.is_mounted():
376                    module.fail_json(msg='Unable to activate mounted BE!')
377
378            (rc, out, err) = be.activate_be()
379
380            if rc != 0:
381                module.fail_json(msg='Error while activating BE: "%s"' % err,
382                                 name=be.name,
383                                 stderr=err,
384                                 rc=rc)
385    elif be.state == 'mounted':
386        if not be.is_mounted():
387            if module.check_mode:
388                module.exit_json(changed=True)
389
390            (rc, out, err) = be.mount_be()
391
392            if rc != 0:
393                module.fail_json(msg='Error while mounting BE: "%s"' % err,
394                                 name=be.name,
395                                 stderr=err,
396                                 rc=rc)
397
398    elif be.state == 'unmounted':
399        if be.is_mounted():
400            if module.check_mode:
401                module.exit_json(changed=True)
402
403            (rc, out, err) = be.unmount_be()
404
405            if rc != 0:
406                module.fail_json(msg='Error while unmounting BE: "%s"' % err,
407                                 name=be.name,
408                                 stderr=err,
409                                 rc=rc)
410
411    if rc is None:
412        result['changed'] = False
413    else:
414        result['changed'] = True
415
416    if out:
417        result['stdout'] = out
418    if err:
419        result['stderr'] = err
420
421    module.exit_json(**result)
422
423
424if __name__ == '__main__':
425    main()
426