1"""
2Support for the softwareupdate command on MacOS.
3"""
4
5import os
6import re
7
8import salt.utils.data
9import salt.utils.files
10import salt.utils.mac_utils
11import salt.utils.path
12import salt.utils.platform
13from salt.exceptions import CommandExecutionError, SaltInvocationError
14
15__virtualname__ = "softwareupdate"
16
17
18def __virtual__():
19    """
20    Only for MacOS
21    """
22    if not salt.utils.platform.is_darwin():
23        return (
24            False,
25            "The softwareupdate module could not be loaded: "
26            "module only works on MacOS systems.",
27        )
28
29    return __virtualname__
30
31
32def _get_available(recommended=False, restart=False, shut_down=False):
33    """
34    Utility function to get all available update packages.
35
36    Sample return date:
37    { 'updatename': '1.2.3-45', ... }
38    """
39    cmd = ["softwareupdate", "--list"]
40    out = salt.utils.mac_utils.execute_return_result(cmd)
41
42    if __grains__["osrelease_info"][0] > 10 or __grains__["osrelease_info"][1] >= 15:
43        # Example output:
44        # Software Update Tool
45        #
46        # Finding available software
47        # Software Update found the following new or updated software:
48        # * Label: Command Line Tools beta 5 for Xcode-11.0
49        #     Title: Command Line Tools beta 5 for Xcode, Version: 11.0, Size: 224804K, Recommended: YES,
50        # * Label: macOS Catalina Developer Beta-6
51        #     Title: macOS Catalina Public Beta, Version: 5, Size: 3084292K, Recommended: YES, Action: restart,
52        # * Label: BridgeOSUpdateCustomer
53        #     Title: BridgeOSUpdateCustomer, Version: 10.15.0.1.1.1560926689, Size: 390674K, Recommended: YES, Action: shut down,
54        # - Label: iCal-1.0.2
55        #     Title: iCal, Version: 1.0.2, Size: 6520K,
56        rexp = re.compile(
57            r"(?m)"  # Turn on multiline matching
58            r"^\s*[*-] Label: "  # Name lines start with * or - and "Label: "
59            r"(?P<name>[^ ].*)[\r\n]"  # Capture the rest of that line; this is the update name.
60            r".*Version: (?P<version>[^,]*), "  # Grab the version number.
61            r"Size: (?P<size>[^,]*),\s*"  # Grab the size; unused at this time.
62            r"(?P<recommended>Recommended: YES,)?\s*"  # Optionally grab the recommended flag.
63            r"(?P<action>Action: (?:restart|shut down),)?"  # Optionally grab an action.
64        )
65    else:
66        # Example output:
67        # Software Update Tool
68        #
69        # Finding available software
70        # Software Update found the following new or updated software:
71        #    * Command Line Tools (macOS Mojave version 10.14) for Xcode-10.3
72        #        Command Line Tools (macOS Mojave version 10.14) for Xcode (10.3), 199140K [recommended]
73        #    * macOS 10.14.1 Update
74        #        macOS 10.14.1 Update (10.14.1), 199140K [recommended] [restart]
75        #    * BridgeOSUpdateCustomer
76        #        BridgeOSUpdateCustomer (10.14.4.1.1.1555388607), 328394K, [recommended] [shut down]
77        #    - iCal-1.0.2
78        #        iCal, (1.0.2), 6520K
79        rexp = re.compile(
80            r"(?m)"  # Turn on multiline matching
81            r"^\s+[*-] "  # Name lines start with 3 spaces and either a * or a -.
82            r"(?P<name>.*)[\r\n]"  # The rest of that line is the name.
83            r".*\((?P<version>[^ \)]*)"  # Capture the last parenthesized value on the next line.
84            r"[^\r\n\[]*(?P<recommended>\[recommended\])?\s?"  # Capture [recommended] if there.
85            r"(?P<action>\[(?:restart|shut down)\])?"  # Capture an action if present.
86        )
87
88    # Build a list of lambda funcs to apply to matches to filter based
89    # on our args.
90    conditions = []
91    if salt.utils.data.is_true(recommended):
92        conditions.append(lambda m: m.group("recommended"))
93    if salt.utils.data.is_true(restart):
94        conditions.append(
95            lambda m: "restart" in (m.group("action") or "")
96        )  # pylint: disable=superfluous-parens
97    if salt.utils.data.is_true(shut_down):
98        conditions.append(
99            lambda m: "shut down" in (m.group("action") or "")
100        )  # pylint: disable=superfluous-parens
101
102    return {
103        m.group("name"): m.group("version")
104        for m in rexp.finditer(out)
105        if all(f(m) for f in conditions)
106    }
107
108
109def list_available(recommended=False, restart=False, shut_down=False):
110    """
111    List all available updates.
112
113    :param bool recommended: Show only recommended updates.
114
115    :param bool restart: Show only updates that require a restart.
116
117    :return: Returns a dictionary containing the updates
118    :rtype: dict
119
120    CLI Example:
121
122    .. code-block:: bash
123
124       salt '*' softwareupdate.list_available
125    """
126    return _get_available(recommended, restart, shut_down)
127
128
129def ignore(name):
130    """
131    Ignore a specific program update. When an update is ignored the '-' and
132    version number at the end will be omitted, so "SecUpd2014-001-1.0" becomes
133    "SecUpd2014-001". It will be removed automatically if present. An update
134    is successfully ignored when it no longer shows up after list_updates.
135
136    :param name: The name of the update to add to the ignore list.
137    :ptype: str
138
139    :return: True if successful, False if not
140    :rtype: bool
141
142    CLI Example:
143
144    .. code-block:: bash
145
146       salt '*' softwareupdate.ignore <update-name>
147    """
148    # remove everything after and including the '-' in the updates name.
149    to_ignore = name.rsplit("-", 1)[0]
150
151    cmd = ["softwareupdate", "--ignore", to_ignore]
152    salt.utils.mac_utils.execute_return_success(cmd)
153
154    return to_ignore in list_ignored()
155
156
157def list_ignored():
158    """
159    List all updates that have been ignored. Ignored updates are shown
160    without the '-' and version number at the end, this is how the
161    softwareupdate command works.
162
163    :return: The list of ignored updates
164    :rtype: list
165
166    CLI Example:
167
168    .. code-block:: bash
169
170       salt '*' softwareupdate.list_ignored
171    """
172    cmd = ["softwareupdate", "--list", "--ignore"]
173    out = salt.utils.mac_utils.execute_return_result(cmd)
174
175    # rep parses lines that look like the following:
176    #     "Safari6.1.2MountainLion-6.1.2",
177    # or:
178    #     Safari6.1.2MountainLion-6.1.2
179    rexp = re.compile('(?m)^    ["]?' r'([^,|\s].*[^"|\n|,])[,|"]?')
180
181    return rexp.findall(out)
182
183
184def reset_ignored():
185    """
186    Make sure the ignored updates are not ignored anymore,
187    returns a list of the updates that are no longer ignored.
188
189    :return: True if the list was reset, Otherwise False
190    :rtype: bool
191
192    CLI Example:
193
194    .. code-block:: bash
195
196       salt '*' softwareupdate.reset_ignored
197    """
198    cmd = ["softwareupdate", "--reset-ignored"]
199    salt.utils.mac_utils.execute_return_success(cmd)
200
201    return list_ignored() == []
202
203
204def schedule_enabled():
205    """
206    Check the status of automatic update scheduling.
207
208    :return: True if scheduling is enabled, False if disabled
209
210    :rtype: bool
211
212    CLI Example:
213
214    .. code-block:: bash
215
216       salt '*' softwareupdate.schedule_enabled
217    """
218    cmd = ["softwareupdate", "--schedule"]
219    ret = salt.utils.mac_utils.execute_return_result(cmd)
220
221    enabled = ret.split()[-1]
222
223    return salt.utils.mac_utils.validate_enabled(enabled) == "on"
224
225
226def schedule_enable(enable):
227    """
228    Enable/disable automatic update scheduling.
229
230    :param enable: True/On/Yes/1 to turn on automatic updates. False/No/Off/0
231        to turn off automatic updates. If this value is empty, the current
232        status will be returned.
233
234    :type: bool str
235
236    :return: True if scheduling is enabled, False if disabled
237    :rtype: bool
238
239    CLI Example:
240
241    .. code-block:: bash
242
243       salt '*' softwareupdate.schedule_enable on|off
244    """
245    status = salt.utils.mac_utils.validate_enabled(enable)
246
247    cmd = [
248        "softwareupdate",
249        "--schedule",
250        salt.utils.mac_utils.validate_enabled(status),
251    ]
252    salt.utils.mac_utils.execute_return_success(cmd)
253
254    return salt.utils.mac_utils.validate_enabled(schedule_enabled()) == status
255
256
257def update_all(recommended=False, restart=True):
258    """
259    Install all available updates. Returns a dictionary containing the name
260    of the update and the status of its installation.
261
262    :param bool recommended: If set to True, only install the recommended
263        updates. If set to False (default) all updates are installed.
264
265    :param bool restart: Set this to False if you do not want to install updates
266        that require a restart. Default is True
267
268    :return: A dictionary containing the updates that were installed and the
269        status of its installation. If no updates were installed an empty
270        dictionary is returned.
271
272    :rtype: dict
273
274    CLI Example:
275
276    .. code-block:: bash
277
278       salt '*' softwareupdate.update_all
279    """
280    to_update = _get_available(recommended, restart)
281
282    if not to_update:
283        return {}
284
285    for _update in to_update:
286        cmd = ["softwareupdate", "--install", _update]
287        salt.utils.mac_utils.execute_return_success(cmd)
288
289    ret = {}
290    updates_left = _get_available()
291
292    for _update in to_update:
293        ret[_update] = True if _update not in updates_left else False
294
295    return ret
296
297
298def update(name):
299    """
300    Install a named update.
301
302    :param str name: The name of the of the update to install.
303
304    :return: True if successfully updated, otherwise False
305    :rtype: bool
306
307    CLI Example:
308
309    .. code-block:: bash
310
311       salt '*' softwareupdate.update <update-name>
312    """
313    if not update_available(name):
314        raise SaltInvocationError("Update not available: {}".format(name))
315
316    cmd = ["softwareupdate", "--install", name]
317    salt.utils.mac_utils.execute_return_success(cmd)
318
319    return not update_available(name)
320
321
322def update_available(name):
323    """
324    Check whether or not an update is available with a given name.
325
326    :param str name: The name of the update to look for
327
328    :return: True if available, False if not
329    :rtype: bool
330
331    CLI Example:
332
333    .. code-block:: bash
334
335       salt '*' softwareupdate.update_available <update-name>
336       salt '*' softwareupdate.update_available "<update with whitespace>"
337    """
338    return name in _get_available()
339
340
341def list_downloads():
342    """
343    Return a list of all updates that have been downloaded locally.
344
345    :return: A list of updates that have been downloaded
346    :rtype: list
347
348    CLI Example:
349
350    .. code-block:: bash
351
352       salt '*' softwareupdate.list_downloads
353    """
354    outfiles = []
355    for root, subFolder, files in salt.utils.path.os_walk("/Library/Updates"):
356        for f in files:
357            outfiles.append(os.path.join(root, f))
358
359    dist_files = []
360    for f in outfiles:
361        if f.endswith(".dist"):
362            dist_files.append(f)
363
364    ret = []
365    for update in _get_available():
366        for f in dist_files:
367            with salt.utils.files.fopen(f) as fhr:
368                if update.rsplit("-", 1)[0] in salt.utils.stringutils.to_unicode(
369                    fhr.read()
370                ):
371                    ret.append(update)
372
373    return ret
374
375
376def download(name):
377    """
378    Download a named update so that it can be installed later with the
379    ``update`` or ``update_all`` functions
380
381    :param str name: The update to download.
382
383    :return: True if successful, otherwise False
384    :rtype: bool
385
386    CLI Example:
387
388    .. code-block:: bash
389
390       salt '*' softwareupdate.download <update name>
391    """
392    if not update_available(name):
393        raise SaltInvocationError("Update not available: {}".format(name))
394
395    if name in list_downloads():
396        return True
397
398    cmd = ["softwareupdate", "--download", name]
399    salt.utils.mac_utils.execute_return_success(cmd)
400
401    return name in list_downloads()
402
403
404def download_all(recommended=False, restart=True):
405    """
406    Download all available updates so that they can be installed later with the
407    ``update`` or ``update_all`` functions. It returns a list of updates that
408    are now downloaded.
409
410    :param bool recommended: If set to True, only install the recommended
411        updates. If set to False (default) all updates are installed.
412
413    :param bool restart: Set this to False if you do not want to install updates
414        that require a restart. Default is True
415
416    :return: A list containing all downloaded updates on the system.
417    :rtype: list
418
419    CLI Example:
420
421    .. code-block:: bash
422
423       salt '*' softwareupdate.download_all
424    """
425    to_download = _get_available(recommended, restart)
426
427    for name in to_download:
428        download(name)
429
430    return list_downloads()
431
432
433def get_catalog():
434    """
435    .. versionadded:: 2016.3.0
436
437    Get the current catalog being used for update lookups. Will return a url if
438    a custom catalog has been specified. Otherwise the word 'Default' will be
439    returned
440
441    :return: The catalog being used for update lookups
442    :rtype: str
443
444    CLI Example:
445
446    .. code-block:: bash
447
448        salt '*' softwareupdates.get_catalog
449    """
450    cmd = ["defaults", "read", "/Library/Preferences/com.apple.SoftwareUpdate.plist"]
451    out = salt.utils.mac_utils.execute_return_result(cmd)
452
453    if "AppleCatalogURL" in out:
454        cmd.append("AppleCatalogURL")
455        out = salt.utils.mac_utils.execute_return_result(cmd)
456        return out
457    elif "CatalogURL" in out:
458        cmd.append("CatalogURL")
459        out = salt.utils.mac_utils.execute_return_result(cmd)
460        return out
461    else:
462        return "Default"
463
464
465def set_catalog(url):
466    """
467    .. versionadded:: 2016.3.0
468
469    Set the Software Update Catalog to the URL specified
470
471    :param str url: The url to the update catalog
472
473    :return: True if successful, False if not
474    :rtype: bool
475
476    CLI Example:
477
478    .. code-block:: bash
479
480        salt '*' softwareupdates.set_catalog http://swupd.local:8888/index.sucatalog
481    """
482    # This command always returns an error code, though it completes
483    # successfully. Success will be determined by making sure get_catalog
484    # returns the passed url
485    cmd = ["softwareupdate", "--set-catalog", url]
486
487    try:
488        salt.utils.mac_utils.execute_return_success(cmd)
489    except CommandExecutionError as exc:
490        pass
491
492    return get_catalog() == url
493
494
495def reset_catalog():
496    """
497    .. versionadded:: 2016.3.0
498
499    Reset the Software Update Catalog to the default.
500
501    :return: True if successful, False if not
502    :rtype: bool
503
504    CLI Example:
505
506    .. code-block:: bash
507
508        salt '*' softwareupdates.reset_catalog
509    """
510    # This command always returns an error code, though it completes
511    # successfully. Success will be determined by making sure get_catalog
512    # returns 'Default'
513    cmd = ["softwareupdate", "--clear-catalog"]
514
515    try:
516        salt.utils.mac_utils.execute_return_success(cmd)
517    except CommandExecutionError as exc:
518        pass
519
520    return get_catalog() == "Default"
521