1""" 2Helper functions for use by mac modules 3.. versionadded:: 2016.3.0 4""" 5 6import logging 7import os 8import plistlib 9import subprocess 10import time 11import xml.parsers.expat 12 13import salt.grains.extra 14import salt.modules.cmdmod 15import salt.utils.args 16import salt.utils.files 17import salt.utils.path 18import salt.utils.platform 19import salt.utils.stringutils 20import salt.utils.timed_subprocess 21from salt.exceptions import ( 22 CommandExecutionError, 23 SaltInvocationError, 24 TimedProcTimeoutError, 25) 26 27try: 28 import pwd 29except ImportError: 30 # The pwd module is not available on all platforms 31 pass 32 33 34DEFAULT_SHELL = salt.grains.extra.shell()["shell"] 35 36# Set up logging 37log = logging.getLogger(__name__) 38 39__virtualname__ = "mac_utils" 40 41__salt__ = { 42 "cmd.run_all": salt.modules.cmdmod._run_all_quiet, 43 "cmd.run": salt.modules.cmdmod._run_quiet, 44} 45 46 47def __virtual__(): 48 """ 49 Load only on Mac OS 50 """ 51 if not salt.utils.platform.is_darwin(): 52 return ( 53 False, 54 "The mac_utils utility could not be loaded: " 55 "utility only works on MacOS systems.", 56 ) 57 58 return __virtualname__ 59 60 61def _run_all(cmd): 62 """ 63 64 Args: 65 cmd: 66 67 Returns: 68 69 """ 70 if not isinstance(cmd, list): 71 cmd = salt.utils.args.shlex_split(cmd, posix=False) 72 73 for idx, item in enumerate(cmd): 74 if not isinstance(cmd[idx], str): 75 cmd[idx] = str(cmd[idx]) 76 77 cmd = " ".join(cmd) 78 79 run_env = os.environ.copy() 80 81 kwargs = { 82 "cwd": None, 83 "shell": DEFAULT_SHELL, 84 "env": run_env, 85 "stdin": None, 86 "stdout": subprocess.PIPE, 87 "stderr": subprocess.PIPE, 88 "with_communicate": True, 89 "timeout": None, 90 "bg": False, 91 } 92 93 try: 94 proc = salt.utils.timed_subprocess.TimedProc(cmd, **kwargs) 95 96 except OSError as exc: 97 raise CommandExecutionError( 98 "Unable to run command '{}' with the context '{}', reason: {}".format( 99 cmd, kwargs, exc 100 ) 101 ) 102 103 ret = {} 104 105 try: 106 proc.run() 107 except TimedProcTimeoutError as exc: 108 ret["stdout"] = str(exc) 109 ret["stderr"] = "" 110 ret["retcode"] = 1 111 ret["pid"] = proc.process.pid 112 return ret 113 114 out, err = proc.stdout, proc.stderr 115 116 if out is not None: 117 out = salt.utils.stringutils.to_str(out).rstrip() 118 if err is not None: 119 err = salt.utils.stringutils.to_str(err).rstrip() 120 121 ret["pid"] = proc.process.pid 122 ret["retcode"] = proc.process.returncode 123 ret["stdout"] = out 124 ret["stderr"] = err 125 126 return ret 127 128 129def _check_launchctl_stderr(ret): 130 """ 131 helper class to check the launchctl stderr. 132 launchctl does not always return bad exit code 133 if there is a failure 134 """ 135 err = ret["stderr"].lower() 136 if "service is disabled" in err: 137 return True 138 return False 139 140 141def execute_return_success(cmd): 142 """ 143 Executes the passed command. Returns True if successful 144 145 :param str cmd: The command to run 146 147 :return: True if successful, otherwise False 148 :rtype: bool 149 150 :raises: Error if command fails or is not supported 151 """ 152 153 ret = _run_all(cmd) 154 log.debug("Execute return success %s: %r", cmd, ret) 155 156 if ret["retcode"] != 0 or "not supported" in ret["stdout"].lower(): 157 msg = "Command Failed: {}\n".format(cmd) 158 msg += "Return Code: {}\n".format(ret["retcode"]) 159 msg += "Output: {}\n".format(ret["stdout"]) 160 msg += "Error: {}\n".format(ret["stderr"]) 161 raise CommandExecutionError(msg) 162 163 return True 164 165 166def execute_return_result(cmd): 167 """ 168 Executes the passed command. Returns the standard out if successful 169 170 :param str cmd: The command to run 171 172 :return: The standard out of the command if successful, otherwise returns 173 an error 174 :rtype: str 175 176 :raises: Error if command fails or is not supported 177 """ 178 ret = _run_all(cmd) 179 180 if ret["retcode"] != 0 or "not supported" in ret["stdout"].lower(): 181 msg = "Command Failed: {}\n".format(cmd) 182 msg += "Return Code: {}\n".format(ret["retcode"]) 183 msg += "Output: {}\n".format(ret["stdout"]) 184 msg += "Error: {}\n".format(ret["stderr"]) 185 raise CommandExecutionError(msg) 186 187 return ret["stdout"] 188 189 190def parse_return(data): 191 """ 192 Returns the data portion of a string that is colon separated. 193 194 :param str data: The string that contains the data to be parsed. Usually the 195 standard out from a command 196 197 For example: 198 ``Time Zone: America/Denver`` 199 will return: 200 ``America/Denver`` 201 """ 202 203 if ": " in data: 204 return data.split(": ")[1] 205 if ":\n" in data: 206 return data.split(":\n")[1] 207 else: 208 return data 209 210 211def validate_enabled(enabled): 212 """ 213 Helper function to validate the enabled parameter. Boolean values are 214 converted to "on" and "off". String values are checked to make sure they are 215 either "on" or "off"/"yes" or "no". Integer ``0`` will return "off". All 216 other integers will return "on" 217 218 :param enabled: Enabled can be boolean True or False, Integers, or string 219 values "on" and "off"/"yes" and "no". 220 :type: str, int, bool 221 222 :return: "on" or "off" or errors 223 :rtype: str 224 """ 225 if isinstance(enabled, str): 226 if enabled.lower() not in ["on", "off", "yes", "no"]: 227 msg = ( 228 "\nMac Power: Invalid String Value for Enabled.\n" 229 "String values must be 'on' or 'off'/'yes' or 'no'.\n" 230 "Passed: {}".format(enabled) 231 ) 232 raise SaltInvocationError(msg) 233 234 return "on" if enabled.lower() in ["on", "yes"] else "off" 235 236 return "on" if bool(enabled) else "off" 237 238 239def confirm_updated(value, check_fun, normalize_ret=False, wait=5): 240 """ 241 Wait up to ``wait`` seconds for a system parameter to be changed before 242 deciding it hasn't changed. 243 244 :param str value: The value indicating a successful change 245 246 :param function check_fun: The function whose return is compared with 247 ``value`` 248 249 :param bool normalize_ret: Whether to normalize the return from 250 ``check_fun`` with ``validate_enabled`` 251 252 :param int wait: The maximum amount of seconds to wait for a system 253 parameter to change 254 """ 255 for i in range(wait): 256 state = validate_enabled(check_fun()) if normalize_ret else check_fun() 257 log.debug( 258 "Confirm update try: %d func:%r state:%s value:%s", 259 i, 260 check_fun, 261 state, 262 value, 263 ) 264 if value in state: 265 return True 266 time.sleep(1) 267 return False 268 269 270def launchctl(sub_cmd, *args, **kwargs): 271 """ 272 Run a launchctl command and raise an error if it fails 273 274 Args: additional args are passed to launchctl 275 sub_cmd (str): Sub command supplied to launchctl 276 277 Kwargs: passed to ``cmd.run_all`` 278 return_stdout (bool): A keyword argument. If true return the stdout of 279 the launchctl command 280 281 Returns: 282 bool: ``True`` if successful 283 str: The stdout of the launchctl command if requested 284 285 Raises: 286 CommandExecutionError: If command fails 287 288 CLI Example: 289 290 .. code-block:: bash 291 292 import salt.utils.mac_service 293 salt.utils.mac_service.launchctl('debug', 'org.cups.cupsd') 294 """ 295 # Get return type 296 return_stdout = kwargs.pop("return_stdout", False) 297 298 # Construct command 299 cmd = ["launchctl", sub_cmd] 300 cmd.extend(args) 301 302 # fix for https://github.com/saltstack/salt/issues/57436 303 if sub_cmd == "bootout": 304 kwargs["success_retcodes"] = [ 305 36, 306 ] 307 308 # Run command 309 kwargs["python_shell"] = False 310 kwargs = salt.utils.args.clean_kwargs(**kwargs) 311 ret = __salt__["cmd.run_all"](cmd, **kwargs) 312 error = _check_launchctl_stderr(ret) 313 314 # Raise an error or return successful result 315 if ret["retcode"] or error: 316 out = "Failed to {} service:\n".format(sub_cmd) 317 out += "stdout: {}\n".format(ret["stdout"]) 318 out += "stderr: {}\n".format(ret["stderr"]) 319 out += "retcode: {}".format(ret["retcode"]) 320 raise CommandExecutionError(out) 321 else: 322 return ret["stdout"] if return_stdout else True 323 324 325def _read_plist_file(root, file_name): 326 """ 327 :param root: The root path of the plist file 328 :param file_name: The name of the plist file 329 :return: An empty dictionary if the plist file was invalid, otherwise, a dictionary with plist data 330 """ 331 file_path = os.path.join(root, file_name) 332 log.debug("read_plist: Gathering service info for %s", file_path) 333 334 # Must be a plist file 335 if not file_path.lower().endswith(".plist"): 336 log.debug("read_plist: Not a plist file: %s", file_path) 337 return {} 338 339 # ignore broken symlinks 340 if not os.path.exists(os.path.realpath(file_path)): 341 log.warning("read_plist: Ignoring broken symlink: %s", file_path) 342 return {} 343 344 try: 345 with salt.utils.files.fopen(file_path, "rb") as handle: 346 plist = plistlib.load(handle) 347 348 except plistlib.InvalidFileException: 349 # Raised in python3 if the file is not XML. 350 # There's nothing we can do; move on to the next one. 351 log.warning( 352 'read_plist: Unable to parse "%s" as it is invalid XML: InvalidFileException.', 353 file_path, 354 ) 355 return {} 356 357 except ValueError as err: 358 # fixes https://github.com/saltstack/salt/issues/58143 359 # choosing not to log a Warning as this would happen on BigSur+ machines. 360 log.debug( 361 "Caught ValueError: '%s', while trying to parse '%s'.", err, file_path 362 ) 363 return {} 364 365 except xml.parsers.expat.ExpatError: 366 # Raised by py3 if the file is XML, but with errors. 367 log.warning( 368 'read_plist: Unable to parse "%s" as it is invalid XML: xml.parsers.expat.ExpatError.', 369 file_path, 370 ) 371 return {} 372 373 if "Label" not in plist: 374 # not all launchd plists contain a Label key 375 log.debug( 376 "read_plist: Service does not contain a Label key. Skipping %s.", file_path 377 ) 378 return {} 379 380 return { 381 "file_name": file_name, 382 "file_path": file_path, 383 "plist": plist, 384 } 385 386 387def _available_services(refresh=False): 388 """ 389 This is a helper function for getting the available macOS services. 390 391 The strategy is to look through the known system locations for 392 launchd plist files, parse them, and use their information for 393 populating the list of services. Services can run without a plist 394 file present, but normally services which have an automated startup 395 will have a plist file, so this is a minor compromise. 396 """ 397 if "available_services" in __context__ and not refresh: 398 log.debug("Found context for available services.") 399 __context__["using_cached_services"] = True 400 return __context__["available_services"] 401 402 launchd_paths = { 403 "/Library/LaunchAgents", 404 "/Library/LaunchDaemons", 405 "/System/Library/LaunchAgents", 406 "/System/Library/LaunchDaemons", 407 } 408 409 agent_path = "/Users/{}/Library/LaunchAgents" 410 launchd_paths.update( 411 { 412 agent_path.format(user) 413 for user in os.listdir("/Users/") 414 if os.path.isdir(agent_path.format(user)) 415 } 416 ) 417 418 result = {} 419 for launch_dir in launchd_paths: 420 for root, dirs, files in salt.utils.path.os_walk(launch_dir): 421 for file_name in files: 422 data = _read_plist_file(root, file_name) 423 if data: 424 result[data["plist"]["Label"].lower()] = data 425 426 # put this in __context__ as this is a time consuming function. 427 # a fix for this issue. https://github.com/saltstack/salt/issues/48414 428 __context__["available_services"] = result 429 # this is a fresh gathering of services, set cached to false 430 __context__["using_cached_services"] = False 431 432 return result 433 434 435def available_services(refresh=False): 436 """ 437 Return a dictionary of all available services on the system 438 439 :param bool refresh: If you wish to refresh the available services 440 as this data is cached on the first run. 441 442 Returns: 443 dict: All available services 444 445 CLI Example: 446 447 .. code-block:: bash 448 449 import salt.utils.mac_service 450 salt.utils.mac_service.available_services() 451 """ 452 log.debug("Loading available services") 453 return _available_services(refresh) 454 455 456def console_user(username=False): 457 """ 458 Gets the UID or Username of the current console user. 459 460 :return: The uid or username of the console user. 461 462 :param bool username: Whether to return the username of the console 463 user instead of the UID. Defaults to False 464 465 :rtype: Interger of the UID, or a string of the username. 466 467 Raises: 468 CommandExecutionError: If we fail to get the UID. 469 470 CLI Example: 471 472 .. code-block:: bash 473 474 import salt.utils.mac_service 475 salt.utils.mac_service.console_user() 476 """ 477 try: 478 # returns the 'st_uid' stat from the /dev/console file. 479 uid = os.stat("/dev/console")[4] 480 except (OSError, IndexError): 481 # we should never get here but raise an error if so 482 raise CommandExecutionError("Failed to get a UID for the console user.") 483 484 if username: 485 return pwd.getpwuid(uid)[0] 486 487 return uid 488 489 490def git_is_stub(): 491 """ 492 Return whether macOS git is the standard OS stub or a real binary. 493 """ 494 # On a fresh macOS install, /usr/bin/git is a stub, which if 495 # accessed, triggers a UI dialog box prompting the user to install 496 # the developer command line tools. We don't want that! So instead, 497 # running the below command will return a path to the installed dev 498 # tools and retcode 0, or print a bunch of info to stderr and 499 # retcode 2. 500 try: 501 cmd = ["/usr/bin/xcode-select", "-p"] 502 _ = subprocess.check_call( 503 cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=1 504 ) 505 log.debug("Xcode command line tools present") 506 return False 507 except subprocess.CalledProcessError: 508 log.debug("Xcode command line tools not present") 509 return True 510