1"""
2Utility functions for zfs
3
4These functions are for dealing with type conversion and basic execution
5
6:maintainer:    Jorge Schrauwen <sjorge@blackdot.be>
7:maturity:      new
8:depends:       salt.utils.stringutils, salt.ext, salt.module.cmdmod
9:platform:      illumos,freebsd,linux
10
11.. versionadded:: 2018.3.1
12
13"""
14
15
16import logging
17import math
18import os
19import re
20from numbers import Number
21
22import salt.modules.cmdmod
23from salt.utils.decorators import memoize as real_memoize
24from salt.utils.odict import OrderedDict
25from salt.utils.stringutils import to_num as str_to_num
26
27# Size conversion data
28re_zfs_size = re.compile(r"^(\d+|\d+(?=\d*)\.\d+)([KkMmGgTtPpEe][Bb]?)$")
29zfs_size = ["K", "M", "G", "T", "P", "E"]
30
31log = logging.getLogger(__name__)
32
33
34def _check_retcode(cmd):
35    """
36    Simple internal wrapper for cmdmod.retcode
37    """
38    return (
39        salt.modules.cmdmod.retcode(cmd, output_loglevel="quiet", ignore_retcode=True)
40        == 0
41    )
42
43
44def _exec(**kwargs):
45    """
46    Simple internal wrapper for cmdmod.run
47    """
48    if "ignore_retcode" not in kwargs:
49        kwargs["ignore_retcode"] = True
50    if "output_loglevel" not in kwargs:
51        kwargs["output_loglevel"] = "quiet"
52    return salt.modules.cmdmod.run_all(**kwargs)
53
54
55def _merge_last(values, merge_after, merge_with=" "):
56    """
57    Merge values all values after X into the last value
58    """
59    if len(values) > merge_after:
60        values = values[0 : (merge_after - 1)] + [
61            merge_with.join(values[(merge_after - 1) :])
62        ]
63
64    return values
65
66
67def _property_normalize_name(name):
68    """
69    Normalizes property names
70    """
71    if "@" in name:
72        name = name[: name.index("@") + 1]
73    return name
74
75
76def _property_detect_type(name, values):
77    """
78    Detect the datatype of a property
79    """
80    value_type = "str"
81    if values.startswith("on | off"):
82        value_type = "bool"
83    elif values.startswith("yes | no"):
84        value_type = "bool_alt"
85    elif values in ["<size>", "<size> | none"]:
86        value_type = "size"
87    elif values in ["<count>", "<count> | none", "<guid>"]:
88        value_type = "numeric"
89    elif name in ["sharenfs", "sharesmb", "canmount"]:
90        value_type = "bool"
91    elif name in ["version", "copies"]:
92        value_type = "numeric"
93    return value_type
94
95
96def _property_create_dict(header, data):
97    """
98    Create a property dict
99    """
100    prop = dict(zip(header, _merge_last(data, len(header))))
101    prop["name"] = _property_normalize_name(prop["property"])
102    prop["type"] = _property_detect_type(prop["name"], prop["values"])
103    prop["edit"] = from_bool(prop["edit"])
104    if "inherit" in prop:
105        prop["inherit"] = from_bool(prop["inherit"])
106    del prop["property"]
107    return prop
108
109
110def _property_parse_cmd(cmd, alias=None):
111    """
112    Parse output of zpool/zfs get command
113    """
114    if not alias:
115        alias = {}
116    properties = {}
117
118    # NOTE: append get to command
119    if cmd[-3:] != "get":
120        cmd += " get"
121
122    # NOTE: parse output
123    prop_hdr = []
124    for prop_data in _exec(cmd=cmd)["stderr"].split("\n"):
125        # NOTE: make the line data more manageable
126        prop_data = prop_data.lower().split()
127
128        # NOTE: skip empty lines
129        if not prop_data:
130            continue
131        # NOTE: parse header
132        elif prop_data[0] == "property":
133            prop_hdr = prop_data
134            continue
135        # NOTE: skip lines after data
136        elif not prop_hdr or prop_data[1] not in ["no", "yes"]:
137            continue
138
139        # NOTE: create property dict
140        prop = _property_create_dict(prop_hdr, prop_data)
141
142        # NOTE: add property to dict
143        properties[prop["name"]] = prop
144        if prop["name"] in alias:
145            properties[alias[prop["name"]]] = prop
146
147        # NOTE: cleanup some duplicate data
148        del prop["name"]
149    return properties
150
151
152def _auto(direction, name, value, source="auto", convert_to_human=True):
153    """
154    Internal magic for from_auto and to_auto
155    """
156    # NOTE: check direction
157    if direction not in ["to", "from"]:
158        return value
159
160    # NOTE: collect property data
161    props = property_data_zpool()
162    if source == "zfs":
163        props = property_data_zfs()
164    elif source == "auto":
165        props.update(property_data_zfs())
166
167    # NOTE: figure out the conversion type
168    value_type = props[name]["type"] if name in props else "str"
169
170    # NOTE: convert
171    if value_type == "size" and direction == "to":
172        return globals()["{}_{}".format(direction, value_type)](value, convert_to_human)
173
174    return globals()["{}_{}".format(direction, value_type)](value)
175
176
177@real_memoize
178def _zfs_cmd():
179    """
180    Return the path of the zfs binary if present
181    """
182    # Get the path to the zfs binary.
183    return salt.utils.path.which("zfs")
184
185
186@real_memoize
187def _zpool_cmd():
188    """
189    Return the path of the zpool binary if present
190    """
191    # Get the path to the zfs binary.
192    return salt.utils.path.which("zpool")
193
194
195def _command(
196    source,
197    command,
198    flags=None,
199    opts=None,
200    property_name=None,
201    property_value=None,
202    filesystem_properties=None,
203    pool_properties=None,
204    target=None,
205):
206    """
207    Build and properly escape a zfs command
208
209    .. note::
210
211        Input is not considered safe and will be passed through
212        to_auto(from_auto('input_here')), you do not need to do so
213        your self first.
214
215    """
216    # NOTE: start with the zfs binary and command
217    cmd = [_zpool_cmd() if source == "zpool" else _zfs_cmd(), command]
218
219    # NOTE: append flags if we have any
220    if flags is None:
221        flags = []
222    for flag in flags:
223        cmd.append(flag)
224
225    # NOTE: append options
226    #       we pass through 'sorted' to guarantee the same order
227    if opts is None:
228        opts = {}
229    for opt in sorted(opts):
230        if not isinstance(opts[opt], list):
231            opts[opt] = [opts[opt]]
232        for val in opts[opt]:
233            cmd.append(opt)
234            cmd.append(to_str(val))
235
236    # NOTE: append filesystem properties (really just options with a key/value)
237    #       we pass through 'sorted' to guarantee the same order
238    if filesystem_properties is None:
239        filesystem_properties = {}
240    for fsopt in sorted(filesystem_properties):
241        cmd.append("-O" if source == "zpool" else "-o")
242        cmd.append(
243            "{key}={val}".format(
244                key=fsopt,
245                val=to_auto(
246                    fsopt,
247                    filesystem_properties[fsopt],
248                    source="zfs",
249                    convert_to_human=False,
250                ),
251            )
252        )
253
254    # NOTE: append pool properties (really just options with a key/value)
255    #       we pass through 'sorted' to guarantee the same order
256    if pool_properties is None:
257        pool_properties = {}
258    for fsopt in sorted(pool_properties):
259        cmd.append("-o")
260        cmd.append(
261            "{key}={val}".format(
262                key=fsopt,
263                val=to_auto(
264                    fsopt,
265                    pool_properties[fsopt],
266                    source="zpool",
267                    convert_to_human=False,
268                ),
269            )
270        )
271
272    # NOTE: append property and value
273    #       the set command takes a key=value pair, we need to support this
274    if property_name is not None:
275        if property_value is not None:
276            if not isinstance(property_name, list):
277                property_name = [property_name]
278            if not isinstance(property_value, list):
279                property_value = [property_value]
280            for key, val in zip(property_name, property_value):
281                cmd.append(
282                    "{key}={val}".format(
283                        key=key,
284                        val=to_auto(key, val, source=source, convert_to_human=False),
285                    )
286                )
287        else:
288            cmd.append(property_name)
289
290    # NOTE: append the target(s)
291    if target is not None:
292        if not isinstance(target, list):
293            target = [target]
294        for tgt in target:
295            # NOTE: skip None list items
296            #       we do not want to skip False and 0!
297            if tgt is None:
298                continue
299            cmd.append(to_str(tgt))
300
301    return " ".join(cmd)
302
303
304def is_supported():
305    """
306    Check the system for ZFS support
307    """
308    # Check for supported platforms
309    # NOTE: ZFS on Windows is in development
310    # NOTE: ZFS on NetBSD is in development
311    on_supported_platform = False
312    if salt.utils.platform.is_sunos():
313        on_supported_platform = True
314    elif salt.utils.platform.is_freebsd() and _check_retcode("kldstat -q -m zfs"):
315        on_supported_platform = True
316    elif salt.utils.platform.is_linux() and os.path.exists("/sys/module/zfs"):
317        on_supported_platform = True
318    elif salt.utils.platform.is_linux() and salt.utils.path.which("zfs-fuse"):
319        on_supported_platform = True
320    elif (
321        salt.utils.platform.is_darwin()
322        and os.path.exists("/Library/Extensions/zfs.kext")
323        and os.path.exists("/dev/zfs")
324    ):
325        on_supported_platform = True
326
327    # Additional check for the zpool command
328    return (salt.utils.path.which("zpool") and on_supported_platform) is True
329
330
331@real_memoize
332def has_feature_flags():
333    """
334    Check if zpool-features is available
335    """
336    # get man location
337    man = salt.utils.path.which("man")
338    return _check_retcode("{man} zpool-features".format(man=man)) if man else False
339
340
341@real_memoize
342def property_data_zpool():
343    """
344    Return a dict of zpool properties
345
346    .. note::
347
348        Each property will have an entry with the following info:
349            - edit : boolean - is this property editable after pool creation
350            - type : str - either bool, bool_alt, size, numeric, or string
351            - values : str - list of possible values
352
353    .. warning::
354
355        This data is probed from the output of 'zpool get' with some supplemental
356        data that is hardcoded. There is no better way to get this information aside
357        from reading the code.
358
359    """
360    # NOTE: man page also mentions a few short forms
361    property_data = _property_parse_cmd(
362        _zpool_cmd(),
363        {
364            "allocated": "alloc",
365            "autoexpand": "expand",
366            "autoreplace": "replace",
367            "listsnapshots": "listsnaps",
368            "fragmentation": "frag",
369        },
370    )
371
372    # NOTE: zpool status/iostat has a few extra fields
373    zpool_size_extra = [
374        "capacity-alloc",
375        "capacity-free",
376        "operations-read",
377        "operations-write",
378        "bandwidth-read",
379        "bandwidth-write",
380        "read",
381        "write",
382    ]
383    zpool_numeric_extra = [
384        "cksum",
385        "cap",
386    ]
387
388    for prop in zpool_size_extra:
389        property_data[prop] = {
390            "edit": False,
391            "type": "size",
392            "values": "<size>",
393        }
394
395    for prop in zpool_numeric_extra:
396        property_data[prop] = {
397            "edit": False,
398            "type": "numeric",
399            "values": "<count>",
400        }
401
402    return property_data
403
404
405@real_memoize
406def property_data_zfs():
407    """
408    Return a dict of zfs properties
409
410    .. note::
411
412        Each property will have an entry with the following info:
413            - edit : boolean - is this property editable after pool creation
414            - inherit : boolean - is this property inheritable
415            - type : str - either bool, bool_alt, size, numeric, or string
416            - values : str - list of possible values
417
418    .. warning::
419
420        This data is probed from the output of 'zfs get' with some supplemental
421        data that is hardcoded. There is no better way to get this information aside
422        from reading the code.
423
424    """
425    return _property_parse_cmd(
426        _zfs_cmd(),
427        {
428            "available": "avail",
429            "logicalreferenced": "lrefer.",
430            "logicalused": "lused.",
431            "referenced": "refer",
432            "volblocksize": "volblock",
433            "compression": "compress",
434            "readonly": "rdonly",
435            "recordsize": "recsize",
436            "refreservation": "refreserv",
437            "reservation": "reserv",
438        },
439    )
440
441
442def from_numeric(value):
443    """
444    Convert zfs numeric to python int
445    """
446    if value == "none":
447        value = None
448    elif value:
449        value = str_to_num(value)
450    return value
451
452
453def to_numeric(value):
454    """
455    Convert python int to zfs numeric
456    """
457    value = from_numeric(value)
458    if value is None:
459        value = "none"
460    return value
461
462
463def from_bool(value):
464    """
465    Convert zfs bool to python bool
466    """
467    if value in ["on", "yes"]:
468        value = True
469    elif value in ["off", "no"]:
470        value = False
471    elif value == "none":
472        value = None
473
474    return value
475
476
477def from_bool_alt(value):
478    """
479    Convert zfs bool_alt to python bool
480    """
481    return from_bool(value)
482
483
484def to_bool(value):
485    """
486    Convert python bool to zfs on/off bool
487    """
488    value = from_bool(value)
489    if isinstance(value, bool):
490        value = "on" if value else "off"
491    elif value is None:
492        value = "none"
493
494    return value
495
496
497def to_bool_alt(value):
498    """
499    Convert python to zfs yes/no value
500    """
501    value = from_bool_alt(value)
502    if isinstance(value, bool):
503        value = "yes" if value else "no"
504    elif value is None:
505        value = "none"
506
507    return value
508
509
510def from_size(value):
511    """
512    Convert zfs size (human readable) to python int (bytes)
513    """
514    match_size = re_zfs_size.match(str(value))
515    if match_size:
516        v_unit = match_size.group(2).upper()[0]
517        v_size = float(match_size.group(1))
518        v_multiplier = math.pow(1024, zfs_size.index(v_unit) + 1)
519        value = v_size * v_multiplier
520        if int(value) == value:
521            value = int(value)
522    elif value is not None:
523        value = str(value)
524
525    return from_numeric(value)
526
527
528def to_size(value, convert_to_human=True):
529    """
530    Convert python int (bytes) to zfs size
531
532    NOTE: http://src.illumos.org/source/xref/illumos-gate/usr/src/lib/pyzfs/common/util.py#114
533    """
534    value = from_size(value)
535    if value is None:
536        value = "none"
537
538    if isinstance(value, Number) and value > 1024 and convert_to_human:
539        v_power = int(math.floor(math.log(value, 1024)))
540        v_multiplier = math.pow(1024, v_power)
541
542        # NOTE: zfs is a bit odd on how it does the rounding,
543        #       see libzfs implementation linked above
544        v_size_float = float(value) / v_multiplier
545        if v_size_float == int(v_size_float):
546            value = "{:.0f}{}".format(
547                v_size_float,
548                zfs_size[v_power - 1],
549            )
550        else:
551            for v_precision in ["{:.2f}{}", "{:.1f}{}", "{:.0f}{}"]:
552                v_size = v_precision.format(
553                    v_size_float,
554                    zfs_size[v_power - 1],
555                )
556                if len(v_size) <= 5:
557                    value = v_size
558                    break
559
560    return value
561
562
563def from_str(value):
564    """
565    Decode zfs safe string (used for name, path, ...)
566    """
567    if value == "none":
568        value = None
569    if value:
570        value = str(value)
571        if value.startswith('"') and value.endswith('"'):
572            value = value[1:-1]
573        value = value.replace('\\"', '"')
574
575    return value
576
577
578def to_str(value):
579    """
580    Encode zfs safe string (used for name, path, ...)
581    """
582    value = from_str(value)
583
584    if value:
585        value = value.replace('"', '\\"')
586        if " " in value:
587            value = '"' + value + '"'
588    elif value is None:
589        value = "none"
590
591    return value
592
593
594def from_auto(name, value, source="auto"):
595    """
596    Convert zfs value to python value
597    """
598    return _auto("from", name, value, source)
599
600
601def to_auto(name, value, source="auto", convert_to_human=True):
602    """
603    Convert python value to zfs value
604    """
605    return _auto("to", name, value, source, convert_to_human)
606
607
608def from_auto_dict(values, source="auto"):
609    """
610    Pass an entire dictionary to from_auto
611
612    .. note::
613        The key will be passed as the name
614
615    """
616    for name, value in values.items():
617        values[name] = from_auto(name, value, source)
618
619    return values
620
621
622def to_auto_dict(values, source="auto", convert_to_human=True):
623    """
624    Pass an entire dictionary to to_auto
625
626    .. note::
627        The key will be passed as the name
628    """
629    for name, value in values.items():
630        values[name] = to_auto(name, value, source, convert_to_human)
631
632    return values
633
634
635def is_snapshot(name):
636    """
637    Check if name is a valid snapshot name
638    """
639    return from_str(name).count("@") == 1
640
641
642def is_bookmark(name):
643    """
644    Check if name is a valid bookmark name
645    """
646    return from_str(name).count("#") == 1
647
648
649def is_dataset(name):
650    """
651    Check if name is a valid filesystem or volume name
652    """
653    return not is_snapshot(name) and not is_bookmark(name)
654
655
656def zfs_command(
657    command,
658    flags=None,
659    opts=None,
660    property_name=None,
661    property_value=None,
662    filesystem_properties=None,
663    target=None,
664):
665    """
666    Build and properly escape a zfs command
667
668    .. note::
669
670        Input is not considered safe and will be passed through
671        to_auto(from_auto('input_here')), you do not need to do so
672        your self first.
673
674    """
675    return _command(
676        "zfs",
677        command=command,
678        flags=flags,
679        opts=opts,
680        property_name=property_name,
681        property_value=property_value,
682        filesystem_properties=filesystem_properties,
683        pool_properties=None,
684        target=target,
685    )
686
687
688def zpool_command(
689    command,
690    flags=None,
691    opts=None,
692    property_name=None,
693    property_value=None,
694    filesystem_properties=None,
695    pool_properties=None,
696    target=None,
697):
698    """
699    Build and properly escape a zpool command
700
701    .. note::
702
703        Input is not considered safe and will be passed through
704        to_auto(from_auto('input_here')), you do not need to do so
705        your self first.
706
707    """
708    return _command(
709        "zpool",
710        command=command,
711        flags=flags,
712        opts=opts,
713        property_name=property_name,
714        property_value=property_value,
715        filesystem_properties=filesystem_properties,
716        pool_properties=pool_properties,
717        target=target,
718    )
719
720
721def parse_command_result(res, label=None):
722    """
723    Parse the result of a zpool/zfs command
724
725    .. note::
726
727        Output on failure is rather predictable.
728        - retcode > 0
729        - each 'error' is a line on stderr
730        - optional 'Usage:' block under those with hits
731
732        We simple check those and return a OrderedDict were
733        we set label = True|False and error = error_messages
734
735    """
736    ret = OrderedDict()
737
738    if label:
739        ret[label] = res["retcode"] == 0
740
741    if res["retcode"] != 0:
742        ret["error"] = []
743        for error in res["stderr"].splitlines():
744            if error.lower().startswith("usage:"):
745                break
746            if error.lower().startswith("use '-f'"):
747                error = error.replace("-f", "force=True")
748            if error.lower().startswith("use '-r'"):
749                error = error.replace("-r", "recursive=True")
750            ret["error"].append(error)
751
752        if ret["error"]:
753            ret["error"] = "\n".join(ret["error"])
754        else:
755            del ret["error"]
756
757    return ret
758
759
760# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
761