import atexit import contextlib import logging import os import pathlib import pprint import re import shutil import textwrap import time from typing import TYPE_CHECKING, Dict, List import attr import distro import packaging.version import psutil import pytest import requests import saltfactories.cli from pytestshellutils.shell import DaemonImpl, Subprocess from pytestshellutils.utils.processes import ( ProcessResult, _get_cmdline, terminate_process, terminate_process_list, ) from pytestskipmarkers.utils import platform from saltfactories.bases import SystemdSaltDaemonImpl from saltfactories.cli import call, key from saltfactories.daemons import api, master, minion from saltfactories.utils import cli_scripts import salt.utils.files from tests.conftest import CODE_DIR from tests.support.pytest.helpers import TestAccount ARTIFACTS_DIR = CODE_DIR / "artifacts" / "pkg" log = logging.getLogger(__name__) @attr.s(kw_only=True, slots=True) class SaltPkgInstall: pkg_system_service: bool = attr.ib(default=False) proc: Subprocess = attr.ib(init=False, repr=False) # Paths root: pathlib.Path = attr.ib(default=None) run_root: pathlib.Path = attr.ib(default=None) ssm_bin: pathlib.Path = attr.ib(default=None) bin_dir: pathlib.Path = attr.ib(default=None) install_dir: pathlib.Path = attr.ib(init=False) binary_paths: Dict[str, List[pathlib.Path]] = attr.ib(init=False) config_path: str = attr.ib(init=False) conf_dir: pathlib.Path = attr.ib() # Test selection flags upgrade: bool = attr.ib(default=False) downgrade: bool = attr.ib(default=False) classic: bool = attr.ib(default=False) # Installing flags no_install: bool = attr.ib(default=False) no_uninstall: bool = attr.ib(default=False) # Distribution/system information distro_id: str = attr.ib(init=False) distro_codename: str = attr.ib(init=False) distro_name: str = attr.ib(init=False) distro_version: str = attr.ib(init=False) # Version information prev_version: str = attr.ib() use_prev_version: str = attr.ib() artifact_version: str = attr.ib(init=False) version: str = attr.ib(init=False) # Package (and management) metadata pkg_mngr: str = attr.ib(init=False) rm_pkg: str = attr.ib(init=False) dbg_pkg: str = attr.ib(init=False) salt_pkgs: List[str] = attr.ib(init=False) pkgs: List[str] = attr.ib(factory=list) file_ext: bool = attr.ib(default=None) relenv: bool = attr.ib(default=True) @proc.default def _default_proc(self): return Subprocess(timeout=240) @distro_id.default def _default_distro_id(self): return distro.id().lower() @distro_codename.default def _default_distro_codename(self): return distro.codename().lower() @distro_name.default def _default_distro_name(self): name = distro.name() if name: if "vmware" in name.lower(): return name.split()[1].lower() return name.split()[0].lower() @distro_version.default def _default_distro_version(self): if self.distro_name in ("photon", "rocky"): return distro.version().split(".")[0] return distro.version().lower() @pkg_mngr.default def _default_pkg_mngr(self): if self.distro_id in ( "almalinux", "rocky", "centos", "redhat", "amzn", "fedora", "photon", ): return "yum" elif self.distro_id in ("ubuntu", "debian"): ret = self.proc.run("apt-get", "update") self._check_retcode(ret) return "apt-get" @rm_pkg.default def _default_rm_pkg(self): if self.distro_id in ( "almalinux", "rocky", "centos", "redhat", "amzn", "fedora", "photon", ): return "remove" elif self.distro_id in ("ubuntu", "debian"): return "purge" @dbg_pkg.default def _default_dbg_pkg(self): dbg_pkg = None if self.distro_id in ( "almalinux", "rocky", "centos", "redhat", "amzn", "fedora", "photon", ): dbg_pkg = "salt-debuginfo" elif self.distro_id in ("ubuntu", "debian"): dbg_pkg = "salt-dbg" return dbg_pkg @salt_pkgs.default def _default_salt_pkgs(self): salt_pkgs = [ "salt-api", "salt-syndic", "salt-ssh", "salt-master", "salt-cloud", "salt-minion", ] if self.distro_id in ( "almalinux", "rocky", "centos", "redhat", "amzn", "fedora", "photon", ): salt_pkgs.append("salt") elif self.distro_id in ("ubuntu", "debian"): salt_pkgs.append("salt-common") if packaging.version.parse(self.version) >= packaging.version.parse("3006.3"): if self.dbg_pkg: salt_pkgs.append(self.dbg_pkg) return salt_pkgs @install_dir.default def _default_install_dir(self): if platform.is_windows(): install_dir = pathlib.Path( os.getenv("ProgramFiles"), "Salt Project", "Salt" ).resolve() elif platform.is_darwin(): install_dir = pathlib.Path("/opt", "salt") else: install_dir = pathlib.Path("/opt", "saltstack", "salt") return install_dir @config_path.default def _default_config_path(self): """ Default location for salt configurations """ if platform.is_windows(): config_path = pathlib.Path("C:\\salt", "etc", "salt") else: config_path = pathlib.Path("/etc", "salt") return config_path @version.default def _default_version(self): """ The version to be installed at the start """ if not self.upgrade and not self.use_prev_version: version = self.artifact_version else: version = self.prev_version parsed = packaging.version.parse(version) version = f"{parsed.major}.{parsed.minor}" # ensure services stopped on Debian/Ubuntu (minic install for RedHat - non-starting) if self.distro_id in ("ubuntu", "debian"): self.stop_services() return version @artifact_version.default def _default_artifact_version(self): """ The version of the local salt artifacts being tested, based on regex matching """ version = "" artifacts = list(ARTIFACTS_DIR.glob("**/*.*")) for artifact in artifacts: version = re.search( r"([0-9].*)(\-[0-9].fc|\-[0-9].el|\+ds|\_all|\_any|\_amd64|\_arm64|\-[0-9].am|(\-[0-9]-[a-z]*-[a-z]*[0-9_]*.|\-[0-9]*.*)(exe|msi|pkg|rpm|deb))", artifact.name, ) if version: version = version.groups()[0].replace("_", "-").replace("~", "") version = version.split("-")[0] break if not version: pytest.fail( f"Failed to find package artifacts in '{ARTIFACTS_DIR}'. " f"Directory Contents:\n{pprint.pformat(artifacts)}" ) return version def update_process_path(self): # The installer updates the path for the system, but that doesn't # make it to this python session, so we need to update that os.environ["PATH"] = ";".join([str(self.install_dir), os.getenv("path")]) def __attrs_post_init__(self): self.relenv = packaging.version.parse(self.version) >= packaging.version.parse( "3006.0" ) file_ext_re = "rpm|deb" if platform.is_darwin(): file_ext_re = "pkg" if platform.is_windows(): file_ext_re = "exe|msi" for f_path in ARTIFACTS_DIR.glob("**/*.*"): f_path = str(f_path) if re.search(f"salt-(.*).({file_ext_re})$", f_path, re.IGNORECASE): self.file_ext = os.path.splitext(f_path)[1].strip(".") self.pkgs.append(f_path) if platform.is_windows(): self.root = pathlib.Path(os.getenv("LocalAppData")).resolve() if self.file_ext in ["exe", "msi"]: self.root = self.install_dir.parent self.bin_dir = self.install_dir self.ssm_bin = self.install_dir / "ssm.exe" self.run_root = self.bin_dir / "bin" / "salt.exe" if not self.relenv and not self.classic: self.ssm_bin = self.bin_dir / "bin" / "ssm.exe" else: log.error("Unexpected file extension: %s", self.file_ext) if self.use_prev_version: self.bin_dir = self.install_dir / "bin" self.run_root = self.bin_dir / "salt.exe" self.ssm_bin = self.bin_dir / "ssm.exe" if self.file_ext == "msi" or self.relenv: self.ssm_bin = self.install_dir / "ssm.exe" if ( self.install_dir / "salt-minion.exe" ).exists() and not self.relenv: log.debug( "Removing %s", self.install_dir / "salt-minion.exe" ) (self.install_dir / "salt-minion.exe").unlink() elif platform.is_darwin(): self.root = pathlib.Path("/opt") if self.file_ext == "pkg": self.bin_dir = self.root / "salt" / "bin" self.run_root = self.bin_dir / "run" else: log.error("Unexpected file extension: %s", self.file_ext) log.debug("root: %s", self.root) log.debug("bin_dir: %s", self.bin_dir) log.debug("ssm_bin: %s", self.ssm_bin) log.debug("run_root: %s", self.run_root) if not self.pkgs: pytest.fail("Could not find Salt Artifacts") python_bin = self.install_dir / "bin" / "python3" if platform.is_windows(): python_bin = self.install_dir / "Scripts" / "python.exe" if self.relenv: self.binary_paths = { "call": ["salt-call.exe"], "cp": ["salt-cp.exe"], "minion": ["salt-minion.exe"], "pip": ["salt-pip.exe"], "python": [python_bin], } elif self.classic: self.binary_paths = { "call": [self.install_dir / "salt-call.bat"], "cp": [self.install_dir / "salt-cp.bat"], "minion": [self.install_dir / "salt-minion.bat"], "python": [self.bin_dir / "python.exe"], } self.binary_paths["pip"] = self.binary_paths["python"] + ["-m", "pip"] else: self.binary_paths = { "call": [str(self.run_root), "call"], "cp": [str(self.run_root), "cp"], "minion": [str(self.run_root), "minion"], "pip": [str(self.run_root), "pip"], "python": [str(self.run_root), "shell"], } else: if os.path.exists(self.install_dir / "bin" / "salt"): install_dir = self.install_dir / "bin" else: install_dir = self.install_dir if self.relenv: self.binary_paths = { "salt": [install_dir / "salt"], "api": [install_dir / "salt-api"], "call": [install_dir / "salt-call"], "cloud": [install_dir / "salt-cloud"], "cp": [install_dir / "salt-cp"], "key": [install_dir / "salt-key"], "master": [install_dir / "salt-master"], "minion": [install_dir / "salt-minion"], "proxy": [install_dir / "salt-proxy"], "run": [install_dir / "salt-run"], "ssh": [install_dir / "salt-ssh"], "syndic": [install_dir / "salt-syndic"], "spm": [install_dir / "spm"], "pip": [install_dir / "salt-pip"], "python": [python_bin], } else: self.binary_paths = { "salt": [shutil.which("salt")], "api": [shutil.which("salt-api")], "call": [shutil.which("salt-call")], "cloud": [shutil.which("salt-cloud")], "cp": [shutil.which("salt-cp")], "key": [shutil.which("salt-key")], "master": [shutil.which("salt-master")], "minion": [shutil.which("salt-minion")], "proxy": [shutil.which("salt-proxy")], "run": [shutil.which("salt-run")], "ssh": [shutil.which("salt-ssh")], "syndic": [shutil.which("salt-syndic")], "spm": [shutil.which("spm")], "python": [str(pathlib.Path("/usr/bin/python3"))], } if self.classic: if platform.is_darwin(): # `which` is not catching the right paths on downgrades, explicitly defining them here self.binary_paths = { "salt": [self.bin_dir / "salt"], "api": [self.bin_dir / "salt-api"], "call": [self.bin_dir / "salt-call"], "cloud": [self.bin_dir / "salt-cloud"], "cp": [self.bin_dir / "salt-cp"], "key": [self.bin_dir / "salt-key"], "master": [self.bin_dir / "salt-master"], "minion": [self.bin_dir / "salt-minion"], "proxy": [self.bin_dir / "salt-proxy"], "run": [self.bin_dir / "salt-run"], "ssh": [self.bin_dir / "salt-ssh"], "syndic": [self.bin_dir / "salt-syndic"], "spm": [self.bin_dir / "spm"], "python": [str(self.bin_dir / "python3")], "pip": [str(self.bin_dir / "pip3")], } else: self.binary_paths["pip"] = [str(pathlib.Path("/usr/bin/pip3"))] self.proc.run(*self.binary_paths["pip"], "install", "-U", "pip") self.proc.run( *self.binary_paths["pip"], "install", "-U", "pyopenssl" ) else: self.binary_paths["python"] = [shutil.which("salt"), "shell"] if platform.is_darwin(): self.binary_paths["pip"] = [self.run_root, "pip"] self.binary_paths["spm"] = [shutil.which("salt-spm")] else: self.binary_paths["pip"] = [shutil.which("salt-pip")] log.debug("python_bin: %s", python_bin) log.debug("binary_paths: %s", self.binary_paths) log.debug("install_dir: %s", self.install_dir) @staticmethod def salt_factories_root_dir(system_service: bool = False) -> pathlib.Path: if system_service is False: return None if platform.is_windows(): return pathlib.Path("C:\\salt") if platform.is_darwin(): return pathlib.Path("/opt/salt") return pathlib.Path("/") def _check_retcode(self, ret): """ Helper function to check subprocess.run returncode equals 0 If not raise AssertionError """ if ret.returncode != 0: log.error(ret) assert ret.returncode == 0 return True def _install_pkgs(self, upgrade=False, downgrade=False): if downgrade: self.install_previous(downgrade=downgrade) return True pkg = self.pkgs[0] if platform.is_windows(): if upgrade: self.root = self.install_dir.parent self.bin_dir = self.install_dir self.ssm_bin = self.install_dir / "ssm.exe" if pkg.endswith("exe"): # Install the package log.debug("Installing: %s", str(pkg)) ret = self.proc.run(str(pkg), "/start-minion=0", "/S") self._check_retcode(ret) elif pkg.endswith("msi"): # Install the package log.debug("Installing: %s", str(pkg)) # Write a batch file to run the installer. It is impossible to # perform escaping of the START_MINION property that the MSI # expects unless we do it via a batch file batch_file = pathlib.Path(pkg).parent / "install_msi.cmd" batch_content = f'msiexec /qn /i "{str(pkg)}" START_MINION=""\n' with salt.utils.files.fopen(batch_file, "w") as fp: fp.write(batch_content) # Now run the batch file ret = self.proc.run("cmd.exe", "/c", str(batch_file)) self._check_retcode(ret) else: log.error("Invalid package: %s", pkg) return False # Remove the service installed by the installer log.debug("Removing installed salt-minion service") self.proc.run(str(self.ssm_bin), "remove", "salt-minion", "confirm") # Add installation to the path self.update_process_path() # Install the service using our config if self.pkg_system_service: self._install_ssm_service() elif platform.is_darwin(): daemons_dir = pathlib.Path("/Library", "LaunchDaemons") service_name = "com.saltstack.salt.minion" plist_file = daemons_dir / f"{service_name}.plist" log.debug("Installing: %s", str(pkg)) ret = self.proc.run("installer", "-pkg", str(pkg), "-target", "/") self._check_retcode(ret) # Stop the service installed by the installer self.proc.run("launchctl", "disable", f"system/{service_name}") self.proc.run("launchctl", "bootout", "system", str(plist_file)) elif upgrade: env = os.environ.copy() extra_args = [] if self.distro_id in ("ubuntu", "debian"): env["DEBIAN_FRONTEND"] = "noninteractive" extra_args = [ "-o", "DPkg::Options::=--force-confdef", "-o", "DPkg::Options::=--force-confold", ] log.info("Installing packages:\n%s", pprint.pformat(self.pkgs)) args = extra_args + self.pkgs upgrade_cmd = "upgrade" if self.distro_id == "photon": # tdnf does not detect nightly build versions to be higher version # than release versions upgrade_cmd = "install" ret = self.proc.run( self.pkg_mngr, upgrade_cmd, "-y", *args, env=env, ) else: log.info("Installing packages:\n%s", pprint.pformat(self.pkgs)) ret = self.proc.run(self.pkg_mngr, "install", "-y", *self.pkgs) if not platform.is_darwin() and not platform.is_windows(): # Make sure we don't have any trailing references to old package file locations assert ret.returncode == 0 assert "/saltstack/salt/run" not in ret.stdout log.info(ret) self._check_retcode(ret) def _install_ssm_service(self, service="minion"): """ This function installs the service on Windows using SSM but does not start it. Args: service (str): The name of the service. Default is ``minion`` """ service_name = f"salt-{service}" binary = self.install_dir / f"{service_name}.exe" ret = self.proc.run( str(self.ssm_bin), "install", service_name, binary, "-c", f'"{str(self.conf_dir)}"', ) self._check_retcode(ret) ret = self.proc.run( str(self.ssm_bin), "set", service_name, "Description", "Salt Minion for testing", ) self._check_retcode(ret) # This doesn't start the service. It will start automatically on reboot # It is set here to make it the same as what the installer does ret = self.proc.run( str(self.ssm_bin), "set", service_name, "Start", "SERVICE_AUTO_START" ) self._check_retcode(ret) ret = self.proc.run( str(self.ssm_bin), "set", service_name, "AppStopMethodConsole", "24000" ) self._check_retcode(ret) ret = self.proc.run( str(self.ssm_bin), "set", service_name, "AppStopMethodWindow", "2000" ) self._check_retcode(ret) ret = self.proc.run( str(self.ssm_bin), "set", service_name, "AppRestartDelay", "60000" ) self._check_retcode(ret) def package_python_version(self): return self.proc.run( str(self.binary_paths["python"][0]), "-c", "import sys; print('{}.{}'.format(*sys.version_info))", ).stdout.strip() def install(self, upgrade=False, downgrade=False): self._install_pkgs(upgrade=upgrade, downgrade=downgrade) if self.distro_id in ("ubuntu", "debian"): self.stop_services() def stop_services(self): """ Debian/Ubuntu distros automatically start the services on install We want to ensure our tests start with the config settings we have set. This will also verify the expected services are up and running. """ retval = True for service in ["salt-syndic", "salt-master", "salt-minion"]: check_run = self.proc.run("systemctl", "status", service) if check_run.returncode != 0: # The system was not started automatically and # we are expecting it to be on install on Debian/Ubuntu systems log.debug("The service %s was not started on install.", service) retval = False else: stop_service = self.proc.run("systemctl", "stop", service) self._check_retcode(stop_service) return retval def restart_services(self): """ Debian/Ubuntu distros automatically start the services We want to ensure our tests start with the config settings we have set, for example: after install the services are stopped (similar to RedHat not starting services on install) This will also verify the expected services are up and running. """ for service in ["salt-minion", "salt-master", "salt-syndic"]: check_run = self.proc.run("systemctl", "status", service) log.debug( "The restart_services status, before restart, for service %s is %s.", service, check_run, ) restart_service = self.proc.run("systemctl", "restart", service) self._check_retcode(restart_service) def install_previous(self, downgrade=False): """ Install previous version. This is used for upgrade tests. """ major_ver = packaging.version.parse(self.prev_version).major relenv = packaging.version.parse(self.prev_version) >= packaging.version.parse( "3006.0" ) distro_name = self.distro_name if distro_name in ("almalinux", "rocky", "centos", "fedora"): distro_name = "redhat" root_url = "https://packages.broadcom.com/artifactory" if self.distro_name in [ "almalinux", "rocky", "redhat", "centos", "amazon", "fedora", "vmware", "photon", ]: # Removing EPEL repo files for fp in pathlib.Path("/etc", "yum.repos.d").glob("epel*"): fp.unlink() if platform.is_aarch64(): arch = "arm64" # Starting with 3006.5, we prioritize the aarch64 repo paths for rpm-based distros if packaging.version.parse( self.prev_version ) >= packaging.version.parse("3006.5"): arch = "aarch64" else: arch = "x86_64" ret = self.proc.run( "rpm", "--import", "https://packages.broadcom.com/artifactory/api/security/keypair/SaltProjectKey/public", ) self._check_retcode(ret) download_file( "https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.repo", f"/etc/yum.repos.d/salt-{distro_name}.repo", ) if self.distro_name == "photon": # yum version on photon doesn't support expire-cache ret = self.proc.run(self.pkg_mngr, "clean", "all") else: ret = self.proc.run(self.pkg_mngr, "clean", "expire-cache") self._check_retcode(ret) cmd_action = "downgrade" if downgrade else "install" pkgs_to_install = self.salt_pkgs.copy() if self.distro_version == "8" and self.classic: # centosstream 8 doesn't downgrade properly using the downgrade command for some reason # So we explicitly install the correct version here list_ret = self.proc.run( self.pkg_mngr, "list", "--available", "salt" ).stdout.split("\n") list_ret = [_.strip() for _ in list_ret] idx = list_ret.index("Available Packages") old_ver = list_ret[idx + 1].split()[1] pkgs_to_install = [f"{pkg}-{old_ver}" for pkg in pkgs_to_install] if self.dbg_pkg: # self.dbg_pkg does not exist on classic packages dbg_exists = [x for x in pkgs_to_install if self.dbg_pkg in x] if dbg_exists: pkgs_to_install.remove(dbg_exists[0]) cmd_action = "install" ret = self.proc.run( self.pkg_mngr, cmd_action, *pkgs_to_install, "-y", ) self._check_retcode(ret) elif distro_name in ["debian", "ubuntu"]: ret = self.proc.run(self.pkg_mngr, "install", "curl", "-y") self._check_retcode(ret) ret = self.proc.run(self.pkg_mngr, "install", "apt-transport-https", "-y") self._check_retcode(ret) ## only classic 3005 has arm64 support if relenv and platform.is_aarch64(): arch = "arm64" elif platform.is_aarch64() and self.classic: arch = "arm64" else: arch = "amd64" pathlib.Path("/etc/apt/keyrings").mkdir(parents=True, exist_ok=True) gpg_full_path = "/etc/apt/keyrings/salt-archive-keyring.gpg" # download the gpg pub key download_file( f"{root_url}/api/security/keypair/SaltProjectKey/public", f"{gpg_full_path}", ) with salt.utils.files.fopen( pathlib.Path("/etc", "apt", "sources.list.d", "salt.list"), "w" ) as fp: fp.write( f"deb [signed-by={gpg_full_path} arch={arch}] " f"{root_url}/saltproject-deb/ {self.distro_codename} main" ) self._check_retcode(ret) cmd = [self.pkg_mngr, "install", *self.salt_pkgs, "-y"] if downgrade: pref_file = pathlib.Path("/etc", "apt", "preferences.d", "salt.pref") pref_file.parent.mkdir(exist_ok=True) # TODO: There's probably something I should put in here to say what version # TODO: But maybe that's done elsewhere, hopefully in self.salt_pkgs pref_file.write_text( textwrap.dedent( f"""\ Package: salt* Pin: origin "{root_url}/saltproject-deb" Pin-Priority: 1001 """ ), encoding="utf-8", ) cmd.append("--allow-downgrades") env = os.environ.copy() env["DEBIAN_FRONTEND"] = "noninteractive" extra_args = [ "-o", "DPkg::Options::=--force-confdef", "-o", "DPkg::Options::=--force-confold", ] self.proc.run(self.pkg_mngr, "update", *extra_args, env=env) cmd.extend(extra_args) ret = self.proc.run(*cmd, env=env) # Pre-relenv packages down get downgraded to cleanly programmatically # They work manually, and the install tests after downgrades will catch problems with the install # Let's not check the returncode if this is the case if not ( downgrade and packaging.version.parse(self.prev_version) < packaging.version.parse("3006.0") ): self._check_retcode(ret) if downgrade: pref_file.unlink() self.stop_services() elif platform.is_windows(): self.bin_dir = self.install_dir / "bin" self.run_root = self.bin_dir / "salt.exe" self.ssm_bin = self.install_dir / "ssm.exe" if self.file_ext == "exe": win_pkg = ( f"Salt-Minion-{self.prev_version}-Py3-AMD64-Setup.{self.file_ext}" ) elif self.file_ext == "msi": win_pkg = f"Salt-Minion-{self.prev_version}-Py3-AMD64.{self.file_ext}" else: log.debug("Unknown windows file extension: %s", self.file_ext) win_pkg_url = ( f"{root_url}/saltproject-generic/windows/{major_ver}/{win_pkg}" ) pkg_path = pathlib.Path(r"C:\TEMP", win_pkg) pkg_path.parent.mkdir(exist_ok=True) download_file(win_pkg_url, pkg_path) if self.file_ext == "msi": # Write a batch file to run the installer. It is impossible to # perform escaping of the START_MINION property that the MSI # expects unless we do it via a batch file batch_file = pkg_path.parent / "install_msi.cmd" batch_content = f'msiexec /qn /i {str(pkg_path)} START_MINION=""' with salt.utils.files.fopen(batch_file, "w") as fp: fp.write(batch_content) # Now run the batch file ret = self.proc.run("cmd.exe", "/c", str(batch_file)) self._check_retcode(ret) else: ret = self.proc.run(pkg_path, "/start-minion=0", "/S") self._check_retcode(ret) log.debug("Removing installed salt-minion service") ret = self.proc.run(str(self.ssm_bin), "remove", "salt-minion", "confirm") self._check_retcode(ret) if self.pkg_system_service: self._install_ssm_service() elif platform.is_darwin(): if relenv and platform.is_aarch64(): arch = "arm64" elif platform.is_aarch64() and self.classic: arch = "arm64" else: arch = "x86_64" mac_pkg = f"salt-{self.prev_version}-py3-{arch}.pkg" mac_pkg_url = f"{root_url}/saltproject-generic/macos/{major_ver}/{mac_pkg}" mac_pkg_path = f"/tmp/{mac_pkg}" if not os.path.exists(mac_pkg_path): download_file( f"{mac_pkg_url}", f"/tmp/{mac_pkg}", ) ret = self.proc.run("installer", "-pkg", mac_pkg_path, "-target", "/") self._check_retcode(ret) def uninstall(self): pkg = self.pkgs[0] if platform.is_windows(): log.info("Uninstalling %s", pkg) if pkg.endswith("exe"): uninst = self.install_dir / "uninst.exe" ret = self.proc.run(uninst, "/S") self._check_retcode(ret) elif pkg.endswith("msi"): ret = self.proc.run("msiexec.exe", "/qn", "/x", pkg) self._check_retcode(ret) elif platform.is_darwin(): # From here: https://stackoverflow.com/a/46118276/4581998 daemons_dir = pathlib.Path("/Library", "LaunchDaemons") for service in ("minion", "master", "api", "syndic"): service_name = f"com.saltstack.salt.{service}" plist_file = daemons_dir / f"{service_name}.plist" # Stop the services self.proc.run("launchctl", "disable", f"system/{service_name}") self.proc.run("launchctl", "bootout", "system", str(plist_file)) # Remove Symlink to salt-config if os.path.exists("/usr/local/sbin/salt-config"): os.unlink("/usr/local/sbin/salt-config") # Remove supporting files self.proc.run( "pkgutil", "--only-files", "--files", "com.saltstack.salt", "|", "grep", "-v", "opt", "|", "tr", "'\n'", "' '", "|", "xargs", "-0", "rm", "-f", ) # Remove directories if os.path.exists("/etc/salt"): shutil.rmtree("/etc/salt") # Remove path if os.path.exists("/etc/paths.d/salt"): os.remove("/etc/paths.d/salt") # Remove receipt self.proc.run("pkgutil", "--forget", "com.saltstack.salt") log.debug("Deleting the onedir directory: %s", self.root / "salt") shutil.rmtree(str(self.root / "salt")) else: log.debug("Un-Installing packages:\n%s", pprint.pformat(self.salt_pkgs)) ret = self.proc.run(self.pkg_mngr, self.rm_pkg, "-y", *self.salt_pkgs) self._check_retcode(ret) def write_launchd_conf(self, service): service_name = f"com.saltstack.salt.{service}" ret = self.proc.run("launchctl", "list", service_name) # 113 means it couldn't find a service with that name if ret.returncode == 113: daemons_dir = pathlib.Path("/Library", "LaunchDaemons") plist_file = daemons_dir / f"{service_name}.plist" # Make sure we're using this plist file if plist_file.exists(): log.warning("Removing existing plist file for service: %s", service) plist_file.unlink() log.debug("Creating plist file for service: %s", service) contents = f"""\ Label {service_name} RunAtLoad KeepAlive ProgramArguments """ for part in self.binary_paths[service]: contents += ( f"""\n {part}\n""" ) contents += f"""\ -c {self.conf_dir} SoftResourceLimits NumberOfFiles 100000 HardResourceLimits NumberOfFiles 100000 """ plist_file.write_text(textwrap.dedent(contents), encoding="utf-8") contents = plist_file.read_text() log.debug("Created '%s'. Contents:\n%s", plist_file, contents) # Delete the plist file upon completion atexit.register(plist_file.unlink) def write_systemd_conf(self, service, binary): ret = self.proc.run("systemctl", "daemon-reload") self._check_retcode(ret) ret = self.proc.run("systemctl", "status", service) if ret.returncode == 4: log.warning( "No systemd unit file was found for service %s. Creating one.", service ) contents = textwrap.dedent( """\ [Unit] Description={service} [Service] KillMode=process Type=notify NotifyAccess=all LimitNOFILE=8192 ExecStart={tgt} -c {conf_dir} [Install] WantedBy=multi-user.target """ ) if isinstance(binary, list) and len(binary) == 1: binary = shutil.which(binary[0]) or binary[0] elif isinstance(binary, list): binary = " ".join(binary) unit_path = pathlib.Path(f"/etc/systemd/system/{service}.service") contents = contents.format( service=service, tgt=binary, conf_dir=self.conf_dir ) log.info("Created '%s'. Contents:\n%s", unit_path, contents) unit_path.write_text(contents, encoding="utf-8") ret = self.proc.run("systemctl", "daemon-reload") atexit.register(unit_path.unlink) self._check_retcode(ret) def __enter__(self): if platform.is_windows(): self.update_process_path() if not self.no_install: if self.upgrade: self.install_previous() else: # assume downgrade, since no_install only used in these two cases self.install() else: self.install() return self def __exit__(self, *_): if not self.no_uninstall: self.uninstall() # Did we left anything running?! procs = [] for proc in psutil.process_iter(): if "salt" in proc.name(): cmdl_strg = " ".join(str(element) for element in _get_cmdline(proc)) if "/opt/saltstack" in cmdl_strg: procs.append(proc) if procs: terminate_process_list(procs, kill=True, slow_stop=True) class PkgSystemdSaltDaemonImpl(SystemdSaltDaemonImpl): # pylint: disable=access-member-before-definition def get_service_name(self): if self._service_name is None: self._service_name = self.factory.script_name return self._service_name # pylint: enable=access-member-before-definition @attr.s(kw_only=True) class PkgLaunchdSaltDaemonImpl(PkgSystemdSaltDaemonImpl): plist_file = attr.ib() @plist_file.default def _default_plist_file(self): daemons_dir = pathlib.Path("/Library", "LaunchDaemons") return daemons_dir / f"{self.get_service_name()}.plist" def get_service_name(self): if self._service_name is None: service_name = super().get_service_name() if "-" in service_name: service_name = service_name.split("-")[-1] self._service_name = f"com.saltstack.salt.{service_name}" return self._service_name def cmdline(self, *args): # pylint: disable=arguments-differ """ Construct a list of arguments to use when starting the subprocess. :param str args: Additional arguments to use when starting the subprocess """ if args: # pragma: no cover log.debug( "%s.run() is ignoring the passed in arguments: %r", self.__class__.__name__, args, ) self._internal_run( "launchctl", "enable", f"system/{self.get_service_name()}", ) return ( "launchctl", "bootstrap", "system", str(self.plist_file), ) def is_running(self): """ Returns true if the sub-process is alive. """ if self._process is None: ret = self._internal_run("launchctl", "list", self.get_service_name()) if ret.stdout == "": return False if "PID" not in ret.stdout: return False pid = None # PID in a line that looks like this # "PID" = 445; for line in ret.stdout.splitlines(): if "PID" in line: pid = line.rstrip(";").split(" = ")[1] if pid is None: return False self._process = psutil.Process(int(pid)) return self._process.is_running() def _terminate(self): """ This method actually terminates the started daemon. """ # We completely override the parent class method because we're not using # the self._terminal property, it's a launchd service if self._process is None: # pragma: no cover # pylint: disable=access-member-before-definition if TYPE_CHECKING: # Make mypy happy assert self._terminal_result return self._terminal_result # pylint: enable=access-member-before-definition atexit.unregister(self.terminate) log.info("Stopping %s", self.factory) pid = self.pid # Collect any child processes information before terminating the process with contextlib.suppress(psutil.NoSuchProcess): for child in psutil.Process(pid).children(recursive=True): # pylint: disable=access-member-before-definition if child not in self._children: self._children.append(child) # pylint: enable=access-member-before-definition if self._process.is_running(): # pragma: no cover cmdline = _get_cmdline(self._process) else: cmdline = [] # Disable the service self._internal_run( "launchctl", "disable", f"system/{self.get_service_name()}", ) # Unload the service self._internal_run("launchctl", "bootout", "system", str(self.plist_file)) if self._process.is_running(): # pragma: no cover try: self._process.wait() except psutil.TimeoutExpired: self._process.terminate() try: self._process.wait() except psutil.TimeoutExpired: pass exitcode = self._process.wait() or 0 # Dereference the internal _process attribute self._process = None # Lets log and kill any child processes left behind, including the main subprocess # if it failed to properly stop terminate_process( pid=pid, kill_children=True, children=self._children, # pylint: disable=access-member-before-definition slow_stop=self.factory.slow_stop, ) # pylint: disable=access-member-before-definition if self._terminal_stdout is not None: self._terminal_stdout.close() if self._terminal_stderr is not None: self._terminal_stderr.close() # pylint: enable=access-member-before-definition stdout = stderr = "" try: self._terminal_result = ProcessResult( returncode=exitcode, stdout=stdout, stderr=stderr, cmdline=cmdline ) log.info("%s %s", self.factory.__class__.__name__, self._terminal_result) return self._terminal_result finally: self._terminal = None self._terminal_stdout = None self._terminal_stderr = None self._terminal_timeout = None self._children = [] @attr.s(kw_only=True) class PkgSsmSaltDaemonImpl(PkgSystemdSaltDaemonImpl): def cmdline(self, *args): # pylint: disable=arguments-differ """ Construct a list of arguments to use when starting the subprocess. :param str args: Additional arguments to use when starting the subprocess """ if args: # pragma: no cover log.debug( "%s.run() is ignoring the passed in arguments: %r", self.__class__.__name__, args, ) return ( str(self.factory.salt_pkg_install.ssm_bin), "start", self.get_service_name(), ) def is_running(self): """ Returns true if the sub-process is alive. """ if self._process is None: n = 1 while True: if self._process is not None: break time.sleep(1) ret = self._internal_run( str(self.factory.salt_pkg_install.ssm_bin), "processes", self.get_service_name(), ) log.warning(ret) if not ret.stdout or (ret.stdout and not ret.stdout.strip()): if n >= 120: return False n += 1 continue for line in ret.stdout.splitlines(): log.warning("Line: %s", line) if not line.strip(): continue mainpid = line.strip().split()[0] self._process = psutil.Process(int(mainpid)) break return self._process.is_running() def _terminate(self): """ This method actually terminates the started daemon. """ # We completely override the parent class method because we're not using the # self._terminal property, it's a systemd service if self._process is None: # pragma: no cover # pylint: disable=access-member-before-definition if TYPE_CHECKING: # Make mypy happy assert self._terminal_result return self._terminal_result # pylint: enable=access-member-before-definition atexit.unregister(self.terminate) log.info("Stopping %s", self.factory) pid = self.pid # Collect any child processes information before terminating the process with contextlib.suppress(psutil.NoSuchProcess): for child in psutil.Process(pid).children(recursive=True): # pylint: disable=access-member-before-definition if child not in self._children: self._children.append(child) # pylint: enable=access-member-before-definition if self._process.is_running(): # pragma: no cover cmdline = _get_cmdline(self._process) else: cmdline = [] # Tell ssm to stop the service try: self._internal_run( str(self.factory.salt_pkg_install.ssm_bin), "stop", self.get_service_name(), ) except FileNotFoundError: pass if self._process.is_running(): # pragma: no cover try: self._process.wait() except psutil.TimeoutExpired: self._process.terminate() try: self._process.wait() except psutil.TimeoutExpired: pass exitcode = self._process.wait() or 0 # Dereference the internal _process attribute self._process = None # Let's log and kill any child processes left behind, including the main # subprocess if it failed to properly stop terminate_process( pid=pid, kill_children=True, children=self._children, # pylint: disable=access-member-before-definition slow_stop=self.factory.slow_stop, ) # pylint: disable=access-member-before-definition if self._terminal_stdout is not None: self._terminal_stdout.close() if self._terminal_stderr is not None: self._terminal_stderr.close() # pylint: enable=access-member-before-definition stdout = stderr = "" try: self._terminal_result = ProcessResult( returncode=exitcode, stdout=stdout, stderr=stderr, cmdline=cmdline ) log.info("%s %s", self.factory.__class__.__name__, self._terminal_result) return self._terminal_result finally: self._terminal = None self._terminal_stdout = None self._terminal_stderr = None self._terminal_timeout = None self._children = [] @attr.s(kw_only=True) class PkgMixin: salt_pkg_install: SaltPkgInstall = attr.ib() def get_script_path(self): if platform.is_darwin() and self.salt_pkg_install.classic: if self.salt_pkg_install.run_root and os.path.exists( self.salt_pkg_install.run_root ): return str(self.salt_pkg_install.run_root) elif os.path.exists(self.salt_pkg_install.bin_dir / self.script_name): return str(self.salt_pkg_install.bin_dir / self.script_name) else: return str(self.salt_pkg_install.install_dir / self.script_name) return super().get_script_path() def cmdline(self, *args, **kwargs): _cmdline = super().cmdline(*args, **kwargs) if _cmdline[0] == self.python_executable: _cmdline.pop(0) return _cmdline @attr.s(kw_only=True) class DaemonPkgMixin(PkgMixin): def __attrs_post_init__(self): if not platform.is_windows() and self.salt_pkg_install.pkg_system_service: if platform.is_darwin(): self.write_launchd_conf() else: self.write_systemd_conf() def get_service_name(self): return self.script_name def write_launchd_conf(self): raise NotImplementedError def write_systemd_conf(self): raise NotImplementedError @attr.s(kw_only=True) class SaltMaster(DaemonPkgMixin, master.SaltMaster): """ Subclassed just to tweak the binary paths if needed and factory classes. """ def __attrs_post_init__(self): self.script_name = "salt-master" master.SaltMaster.__attrs_post_init__(self) DaemonPkgMixin.__attrs_post_init__(self) def _get_impl_class(self): if self.system_service and self.salt_pkg_install.pkg_system_service: if platform.is_windows(): return PkgSsmSaltDaemonImpl if platform.is_darwin(): return PkgLaunchdSaltDaemonImpl return PkgSystemdSaltDaemonImpl return DaemonImpl def write_launchd_conf(self): self.salt_pkg_install.write_launchd_conf("master") def write_systemd_conf(self): self.salt_pkg_install.write_systemd_conf( "salt-master", self.salt_pkg_install.binary_paths["master"] ) def salt_minion_daemon(self, minion_id, **kwargs): return super().salt_minion_daemon( minion_id, factory_class=SaltMinion, salt_pkg_install=self.salt_pkg_install, **kwargs, ) def salt_api_daemon(self, **kwargs): return super().salt_api_daemon( factory_class=SaltApi, salt_pkg_install=self.salt_pkg_install, **kwargs ) def salt_key_cli(self, factory_class=None, **factory_class_kwargs): if not factory_class: factory_class = SaltKey factory_class_kwargs["salt_pkg_install"] = self.salt_pkg_install return super().salt_key_cli( factory_class=factory_class, **factory_class_kwargs, ) def salt_cli(self, factory_class=None, **factory_class_kwargs): if not factory_class: factory_class = SaltCli factory_class_kwargs["salt_pkg_install"] = self.salt_pkg_install return super().salt_cli( factory_class=factory_class, **factory_class_kwargs, ) @attr.s(kw_only=True) class SaltMasterWindows(SaltMaster): """ Subclassed just to tweak the binary paths if needed and factory classes. """ def __attrs_post_init__(self): super().__attrs_post_init__() self.script_name = cli_scripts.generate_script( bin_dir=self.factories_manager.scripts_dir, script_name="salt-master", code_dir=self.factories_manager.code_dir.parent, ) def _get_impl_class(self): return DaemonImpl def cmdline(self, *args, **kwargs): cmdline_ = super().cmdline(*args, **kwargs) if self.python_executable: if cmdline_[0] != self.python_executable: cmdline_.insert(0, self.python_executable) return cmdline_ @attr.s(kw_only=True, slots=True) class SaltMinion(DaemonPkgMixin, minion.SaltMinion): """ Subclassed just to tweak the binary paths if needed and factory classes. """ def __attrs_post_init__(self): self.script_name = "salt-minion" minion.SaltMinion.__attrs_post_init__(self) DaemonPkgMixin.__attrs_post_init__(self) def _get_impl_class(self): if self.system_service and self.salt_pkg_install.pkg_system_service: if platform.is_windows(): return PkgSsmSaltDaemonImpl if platform.is_darwin(): return PkgLaunchdSaltDaemonImpl return PkgSystemdSaltDaemonImpl return DaemonImpl def write_launchd_conf(self): self.salt_pkg_install.write_launchd_conf("minion") def write_systemd_conf(self): self.salt_pkg_install.write_systemd_conf( "salt-minion", self.salt_pkg_install.binary_paths["minion"] ) def salt_call_cli(self, factory_class=None, **factory_class_kwargs): if not factory_class: factory_class = SaltCall factory_class_kwargs["salt_pkg_install"] = self.salt_pkg_install return super().salt_call_cli( factory_class=factory_class, **factory_class_kwargs, ) @attr.s(kw_only=True, slots=True) class SaltApi(DaemonPkgMixin, api.SaltApi): """ Subclassed just to tweak the binary paths if needed. """ def __attrs_post_init__(self): self.script_name = "salt-api" api.SaltApi.__attrs_post_init__(self) DaemonPkgMixin.__attrs_post_init__(self) def _get_impl_class(self): if self.system_service and self.salt_pkg_install.pkg_system_service: if platform.is_windows(): return PkgSsmSaltDaemonImpl if platform.is_darwin(): return PkgLaunchdSaltDaemonImpl return PkgSystemdSaltDaemonImpl return DaemonImpl def write_launchd_conf(self): self.salt_pkg_install.write_launchd_conf("api") def write_systemd_conf(self): self.salt_pkg_install.write_systemd_conf( "salt-api", self.salt_pkg_install.binary_paths["api"], ) @attr.s(kw_only=True, slots=True) class SaltCall(PkgMixin, call.SaltCall): """ Subclassed just to tweak the binary paths if needed. """ def __attrs_post_init__(self): call.SaltCall.__attrs_post_init__(self) self.script_name = "salt-call" @attr.s(kw_only=True, slots=True) class SaltCli(PkgMixin, saltfactories.cli.salt.SaltCli): """ Subclassed just to tweak the binary paths if needed. """ def __attrs_post_init__(self): self.script_name = "salt" saltfactories.cli.salt.SaltCli.__attrs_post_init__(self) @attr.s(kw_only=True, slots=True) class SaltKey(PkgMixin, key.SaltKey): """ Subclassed just to tweak the binary paths if needed. """ def __attrs_post_init__(self): self.script_name = "salt-key" key.SaltKey.__attrs_post_init__(self) @attr.s(kw_only=True, slots=True) class ApiRequest: port: int = attr.ib(repr=False) account: TestAccount = attr.ib(repr=False) session: requests.Session = attr.ib(init=False, repr=False) api_uri: str = attr.ib(init=False) auth_data: Dict[str, str] = attr.ib(init=False) @session.default def _default_session(self): return requests.Session() @api_uri.default def _default_api_uri(self): return f"http://localhost:{self.port}" @auth_data.default def _default_auth_data(self): return { "username": self.account.username, "password": self.account.password, "eauth": "auto", "out": "json", } def post(self, url, data): post_data = dict(**self.auth_data, **data) resp = self.session.post(f"{self.api_uri}/run", data=post_data).json() minion = next(iter(resp["return"][0])) return resp["return"][0][minion] def __enter__(self): self.session.__enter__() return self def __exit__(self, *args): self.session.__exit__(*args) @pytest.helpers.register def download_file(url, dest, auth=None): # NOTE the stream=True parameter below with requests.get(url, stream=True, auth=auth, timeout=60) as r: r.raise_for_status() with salt.utils.files.fopen(dest, "wb") as f: for chunk in r.iter_content(chunk_size=8192): if chunk: f.write(chunk) return dest