""" These commands are used to build Salt packages. """ # pylint: disable=resource-leakage,broad-except,3rd-party-module-not-gated from __future__ import annotations import fnmatch import gzip import hashlib import json import logging import os import pathlib import shutil import sys import tarfile import tempfile import yaml from ptscripts import Context, command_group import tools.utils log = logging.getLogger(__name__) # Define the command group pkg = command_group(name="pkg", help="Packaging Related Commands", description=__doc__) class Recompress: """ Helper class to re-compress a ``.tag.gz`` file to make it reproducible. """ def __init__(self, mtime): self.mtime = int(mtime) def tar_reset(self, tarinfo): """ Reset user, group, mtime, and mode to create reproducible tar. """ tarinfo.uid = tarinfo.gid = 0 tarinfo.uname = tarinfo.gname = "root" tarinfo.mtime = self.mtime if tarinfo.type == tarfile.DIRTYPE: tarinfo.mode = 0o755 else: tarinfo.mode = 0o644 if tarinfo.pax_headers: raise ValueError(tarinfo.name, tarinfo.pax_headers) return tarinfo def recompress(self, targz): """ Re-compress the passed path. """ tempd = pathlib.Path(tempfile.mkdtemp()).resolve() d_src = tempd.joinpath("src") d_src.mkdir() d_tar = tempd.joinpath(targz.stem) d_targz = tempd.joinpath(targz.name) with tarfile.open(d_tar, "w|") as wfile: with tarfile.open(targz, "r:gz") as rfile: rfile.extractall(d_src) extracted_dir = next(pathlib.Path(d_src).iterdir()) for name in sorted(extracted_dir.rglob("*")): wfile.add( str(name), filter=self.tar_reset, recursive=False, arcname=str(name.relative_to(d_src)), ) with open(d_tar, "rb") as rfh: with gzip.GzipFile( fileobj=open(d_targz, "wb"), mode="wb", filename="", mtime=self.mtime ) as gz: # pylint: disable=invalid-name while True: chunk = rfh.read(1024) if not chunk: break gz.write(chunk) targz.unlink() shutil.move(str(d_targz), str(targz)) @pkg.command( name="set-salt-version", arguments={ "salt_version": { "help": ( "The salt version to write to 'salt/_version.txt'. If not passed " "it will be discovered by running 'python3 salt/version.py'." ), "nargs": "?", "default": None, }, "overwrite": { "help": "Overwrite 'salt/_version.txt' if it already exists", }, "validate_version": { "help": "Validate, and normalize, the passed Salt Version", }, "release": { "help": "When true, also update salt/versions.py to set the version as released", }, }, ) def set_salt_version( ctx: Context, salt_version: str, overwrite: bool = False, validate_version: bool = False, release: bool = False, ): """ Write the Salt version to 'salt/_version.txt' """ salt_version_file = tools.utils.REPO_ROOT / "salt" / "_version.txt" if salt_version_file.exists(): if not overwrite: ctx.error("The 'salt/_version.txt' file already exists") ctx.exit(1) salt_version_file.unlink() if salt_version is None: if not tools.utils.REPO_ROOT.joinpath(".git").exists(): ctx.error( "Apparently not running from a Salt repository checkout. " "Unable to discover the Salt version." ) ctx.exit(1) ctx.info("Discovering the Salt version...") ret = ctx.run(shutil.which("python3"), "salt/version.py", capture=True) salt_version = ret.stdout.strip().decode() ctx.info(f"Discovered Salt version: {salt_version!r}") elif validate_version: ctx.info(f"Validating and normalizing the salt version {salt_version!r}...") with ctx.virtualenv( name="set-salt-version", requirements_files=[tools.utils.REPO_ROOT / "requirements" / "base.txt"], ) as venv: code = f""" import sys import salt.version parsed_version = salt.version.SaltStackVersion.parse("{salt_version}") if parsed_version.name is None: # When we run out of names, or we stop supporting version names # we'll need to remove this version check. print("'{{}}' is not a valid Salt Version.".format(parsed_version), file=sys.stderr, flush=True) sys.exit(1) sys.stdout.write(str(parsed_version)) sys.stdout.flush() """ ret = venv.run_code(code, capture=True, check=False) if ret.returncode: ctx.error(ret.stderr.decode()) ctx.exit(ret.returncode) salt_version = ret.stdout.strip().decode() if not tools.utils.REPO_ROOT.joinpath("salt").is_dir(): ctx.error( "The path 'salt/' is not a directory. Unable to write 'salt/_version.txt'" ) ctx.exit(1) try: tools.utils.REPO_ROOT.joinpath("salt/_version.txt").write_text(salt_version) except Exception as exc: ctx.error(f"Unable to write 'salt/_version.txt': {exc}") ctx.exit(1) ctx.info(f"Successfuly wrote {salt_version!r} to 'salt/_version.txt'") version_instance = tools.utils.Version(salt_version) if release and not version_instance.is_prerelease: with open(tools.utils.REPO_ROOT / "salt" / "version.py", "r+") as rwfh: contents = rwfh.read() match = f"info=({version_instance.major}, {version_instance.minor}))" if match in contents: contents = contents.replace( match, f"info=({version_instance.major}, {version_instance.minor}), released=True)", ) rwfh.seek(0) rwfh.write(contents) rwfh.truncate() ctx.info( f"Successfuly marked {salt_version!r} as released in 'salt/version.py'" ) gh_env_file = os.environ.get("GITHUB_ENV", None) if gh_env_file is not None: variable_text = f"SALT_VERSION={salt_version}" ctx.info(f"Writing '{variable_text}' to '$GITHUB_ENV' file:", gh_env_file) with open(gh_env_file, "w", encoding="utf-8") as wfh: wfh.write(f"{variable_text}\n") gh_output_file = os.environ.get("GITHUB_OUTPUT", None) if gh_output_file is not None: variable_text = f"salt-version={salt_version}" ctx.info(f"Writing '{variable_text}' to '$GITHUB_OUTPUT' file:", gh_output_file) with open(gh_output_file, "w", encoding="utf-8") as wfh: wfh.write(f"{variable_text}\n") ctx.exit(0) @pkg.command( name="pre-archive-cleanup", arguments={ "cleanup_path": { "help": ( "The salt version to write to 'salt/_version.txt'. If not passed " "it will be discovered by running 'python3 salt/version.py'." ), "metavar": "PATH_TO_CLEANUP", }, "pkg": { "help": "Perform extended, pre-packaging cleanup routines", }, }, ) def pre_archive_cleanup(ctx: Context, cleanup_path: str, pkg: bool = False): """ Clean the provided path of paths that should not be included in the archive. For example: * `__pycache__` directories * `*.pyc` files * `*.pyo` files When running on Windows and macOS, some additional cleanup is also done. """ with open( str(tools.utils.REPO_ROOT / "pkg" / "common" / "env-cleanup-rules.yml") ) as rfh: patterns = yaml.safe_load(rfh.read()) if pkg: patterns = patterns["pkg"] else: patterns = patterns["ci"] if sys.platform.lower().startswith("win"): patterns = patterns["windows"] elif sys.platform.lower().startswith("darwin"): patterns = patterns["darwin"] else: patterns = patterns["linux"] def unnest_lists(patterns): if isinstance(patterns, list): for pattern in patterns: yield from unnest_lists(pattern) else: yield patterns dir_patterns = set() for pattern in unnest_lists(patterns["dir_patterns"]): dir_patterns.add(pattern) file_patterns = set() for pattern in unnest_lists(patterns["file_patterns"]): file_patterns.add(pattern) for root, dirs, files in os.walk(cleanup_path, topdown=True, followlinks=False): for dirname in dirs: path = pathlib.Path(root, dirname).resolve() if not path.exists(): continue match_path = path.as_posix() for pattern in dir_patterns: if fnmatch.fnmatch(str(match_path), pattern): ctx.info( f"Deleting directory: {match_path}; Matching pattern: {pattern!r}" ) shutil.rmtree(str(path)) break for filename in files: path = pathlib.Path(root, filename).resolve() if not path.exists(): continue match_path = path.as_posix() for pattern in file_patterns: if fnmatch.fnmatch(str(match_path), pattern): ctx.info( f"Deleting file: {match_path}; Matching pattern: {pattern!r}" ) try: os.remove(str(path)) except FileNotFoundError: pass break @pkg.command( name="generate-hashes", arguments={ "files": { "help": "The files to generate the hashes for.", "nargs": "*", }, }, ) def generate_hashes(ctx: Context, files: list[pathlib.Path]): """ Generate "blake2b", "sha512" and "sha3_512" hashes for the passed files. """ for fpath in files: ctx.info(f"* Processing {fpath} ...") hashes = {} for hash_name in ("blake2b", "sha512", "sha3_512"): ctx.info(f" * Calculating {hash_name} ...") with fpath.open("rb") as rfh: try: digest = hashlib.file_digest(rfh, hash_name) # type: ignore[attr-defined] except AttributeError: # Python < 3.11 buf = bytearray(2**18) # Reusable buffer to reduce allocations. view = memoryview(buf) digest = getattr(hashlib, hash_name)() while True: size = rfh.readinto(buf) if size == 0: break # EOF digest.update(view[:size]) digest_file_path = fpath.parent / f"{fpath.name}.{hash_name.upper()}" hexdigest = digest.hexdigest() ctx.info(f" * Writing {digest_file_path} ...") digest_file_path.write_text(digest.hexdigest()) hashes[hash_name] = hexdigest hashes_json_path = fpath.parent / f"{fpath.name}.json" ctx.info(f" * Writing {hashes_json_path} ...") hashes_json_path.write_text(json.dumps(hashes)) ctx.info("Done") @pkg.command( name="source-tarball", venv_config={ "requirements_files": [ tools.utils.REPO_ROOT / "requirements" / "build.txt", ] }, ) def source_tarball(ctx: Context): shutil.rmtree("dist/", ignore_errors=True) timestamp = ctx.run( "git", "show", "-s", "--format=%at", "HEAD", capture=True, ).stdout.strip() env = { **os.environ, **{ "SOURCE_DATE_EPOCH": str(timestamp), }, } ctx.run( "python3", "-m", "build", "--sdist", str(tools.utils.REPO_ROOT), env=env, check=True, ) # Recreate sdist to be reproducible recompress = Recompress(timestamp) for targz in tools.utils.REPO_ROOT.joinpath("dist").glob("*.tar.gz"): ctx.info(f"Re-compressing {targz.relative_to(tools.utils.REPO_ROOT)} ...") recompress.recompress(targz) sha256sum = shutil.which("sha256sum") if sha256sum: packages = [ str(pkg.relative_to(tools.utils.REPO_ROOT)) for pkg in tools.utils.REPO_ROOT.joinpath("dist").iterdir() ] ctx.run("sha256sum", *packages) ctx.run("python3", "-m", "twine", "check", "dist/*", check=True) @pkg.command( name="pypi-upload", venv_config={ "requirements_files": [ tools.utils.REPO_ROOT / "requirements" / "build.txt", ] }, arguments={ "files": { "help": "Files to upload to PyPi", "nargs": "*", }, "test": { "help": "When true, upload to test.pypi.org instead", }, }, ) def pypi_upload(ctx: Context, files: list[pathlib.Path], test: bool = False): ctx.run( "python3", "-m", "twine", "check", *[str(fpath) for fpath in files], check=True ) if test is True: repository_url = "https://test.pypi.org/legacy/" else: repository_url = "https://upload.pypi.org/legacy/" if "TWINE_USERNAME" not in os.environ: os.environ["TWINE_USERNAME"] = "__token__" if "TWINE_PASSWORD" not in os.environ: ctx.error("The 'TWINE_PASSWORD' variable is not set. Cannot upload.") ctx.exit(1) cmdline = [ "twine", "upload", f"--repository-url={repository_url}", "--username=__token__", ] if test is True: cmdline.append("--skip-existing") cmdline.extend([str(fpath) for fpath in files]) ctx.info(f"Running '{' '.join(cmdline)}' ...") ret = ctx.run(*cmdline, check=False) if ret.returncode: ctx.error(ret.stderr.strip().decode()) ctx.exit(ret.returncode) @pkg.command( name="configure-git", arguments={ "user": { "help": "The git global username", "required": False, }, "email": { "help": "The git global email", "required": False, }, }, ) def configure_git( ctx: Context, user: str = "Salt Project Packaging", email: str = "saltproject-packaging@vmware.com", ): cwd = pathlib.Path.cwd() ctx.info("Setting name and email in git global config") ctx.run("git", "config", "--global", "user.name", f"'{user}'") ctx.run("git", "config", "--global", "user.email", f"{email}") ctx.info(f"Adding {str(cwd)} as a safe directory") ctx.run("git", "config", "--global", "--add", "safe.directory", str(cwd)) @pkg.command( name="apply-release-patch", arguments={ "patch": {"help": "The git global username"}, "delete": { "help": "Whether to delete the patch after applying", "required": False, }, }, ) def apply_release_patch(ctx: Context, patch: pathlib.Path, delete: bool = False): patch = patch.resolve() ctx.info("Applying the release patch") ctx.run("git", "am", "--committer-date-is-author-date", patch.name) if delete: ctx.info("Deleting the release patch because --delete was passed") patch.unlink()