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