diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..eff654f3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+*.swp
+__pycache__
+build
+*.pyc
+*~
diff --git a/README.md b/README.md
index 6b7e7c0a..0d0ff4d4 100644
--- a/README.md
+++ b/README.md
@@ -342,17 +342,57 @@ usage: grep_more_ioc [-h] [-d] patt hutch {print,search}
usage: ioc-deploy [-h] [--version] [--name NAME] [--release RELEASE] - [--ioc-dir IOC_DIR] [--github_org GITHUB_ORG] + [--ioc-dir IOC_DIR] [--path-override PATH_OVERRIDE] [--auto-confirm] [--dry-run] [--verbose] + [--github_org GITHUB_ORG] + {update-perms} ... -ioc-deploy is a script for building and deploying ioc tags from github. It -will create a shallow clone of your IOC in the standard release area at the -correct path and "make" it. If the tag directory already exists, the script -will exit. Example command: "ioc-deploy -n ioc-foo-bar -r R1.0.0" This will -clone the repository to the default ioc directory and run make using the -currently set EPICS environment variables. With default settings this will -clone from https://github.com/pcdshub/ioc-foo-bar to -/cds/group/pcds/epics/ioc/foo/bar/R1.0.0 then cd and make. +ioc-deploy is a script for building and deploying ioc tags from github. + +It will take one of two different actions: the normal deploy action, +or a write permissions change on an existing deployed release. + +The normal deploy action will create a shallow clone of your IOC in the +standard release area at the correct path and "make" it. +If the tag directory already exists, the script will exit. + +In the deploy action, after making the IOC, we'll write-protect all files +and all directories. +We'll also write-protect the top-level directory to help indicate completion. + +Note that this means you'll need to restore write permissions if you'd like +to rebuild an existing release on a new architecture or remove it entirely. + +Example command: + +"ioc-deploy -n ioc-foo-bar -r R1.0.0" + +This will clone the repository to the default ioc directory and run make using the +currently set EPICS environment variables, then apply write protection. + +With default settings, this will clone +from https://github.com/pcdshub/ioc-foo-bar +to /cds/group/pcds/epics/ioc/foo/bar/R1.0.0 +then cd and make and chmod as appropriate. + +The second action will not do any git or make actions, it will only find the +release directory and change the file and directory permissions. +This can be done with similar commands as above, adding the subparser command, +and it can be done by passing the specific path you'd like to modify +if this is more convenient for you. + +Example commands: + +"ioc-deploy update-perms rw -n ioc-foo-bar -r R1.0.0" +"ioc-deploy update-perms ro -n ioc-foo-bar -r R1.0.0" +"ioc-deploy update-perms rw -p /cds/group/pcds/epics/ioc/foo/bar/R1.0.0" +"ioc-deploy update-perms ro -p /cds/group/pcds/epics/ioc/foo/bar/R1.0.0" + +positional arguments: + {update-perms} Subcommands (will not deploy): + update-perms Use 'ioc-deploy update-perms' to update the write + permissions of a deployment. See 'ioc-deploy update- + perms --help' for more information. optional arguments: -h, --help show this help message and exit @@ -369,11 +409,56 @@ optional arguments: the environment variable is not set. With your current environment variables, this defaults to /reg/g/pcds/epics/ioc. + --path-override PATH_OVERRIDE, -p PATH_OVERRIDE + If provided, ignore all normal path-selection rules in + favor of the specific provided path. This will let you + deploy IOCs or apply protection rules to arbitrary + specific paths. + --auto-confirm, --confirm, --yes, -y + Skip the confirmation promps, automatically saying yes + to each one. + --dry-run Do not deploy anything, just print what would have + been done. + --verbose, -v, --debug + Display additional debug information. --github_org GITHUB_ORG, --org GITHUB_ORG The github org to deploy IOCs from. This defaults to $GITHUB_ORG, or pcdshub if the environment variable is not set. With your current environment variables, this defaults to pcdshub. + +usage: ioc-deploy update-perms [-h] [--name NAME] [--release RELEASE] + [--ioc-dir IOC_DIR] + [--path-override PATH_OVERRIDE] + [--auto-confirm] [--dry-run] [--verbose] + {ro,rw} + +Update the write permissions of a deployment. This will make all the files +read-only (ro), or owner and group writable (rw). + +positional arguments: + {ro,rw} Select whether to make the deployment permissions + read-only (ro) or read-write (rw). + +optional arguments: + -h, --help show this help message and exit + --name NAME, -n NAME The name of the repository to deploy. This is a + required argument. If it does not exist on github, + we'll also try prepending with 'ioc-common-'. + --release RELEASE, -r RELEASE + The version of the IOC to deploy. This is a required + argument. + --ioc-dir IOC_DIR, -i IOC_DIR + The directory to deploy IOCs in. This defaults to + $EPICS_SITE_TOP/ioc, or /cds/group/pcds/epics/ioc if + the environment variable is not set. With your current + environment variables, this defaults to + /reg/g/pcds/epics/ioc. + --path-override PATH_OVERRIDE, -p PATH_OVERRIDE + If provided, ignore all normal path-selection rules in + favor of the specific provided path. This will let you + deploy IOCs or apply protection rules to arbitrary + specific paths. --auto-confirm, --confirm, --yes, -y Skip the confirmation promps, automatically saying yes to each one. diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index 959d33ee..1cd71949 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -1,35 +1,63 @@ #!/usr/bin/python3 """ ioc-deploy is a script for building and deploying ioc tags from github. -It will create a shallow clone of your IOC in the standard release area at the correct path and "make" it. + +It will take one of two different actions: the normal deploy action, +or a write permissions change on an existing deployed release. + +The normal deploy action will create a shallow clone of your IOC in the +standard release area at the correct path and "make" it. If the tag directory already exists, the script will exit. +In the deploy action, after making the IOC, we'll write-protect all files +and all directories. +We'll also write-protect the top-level directory to help indicate completion. + +Note that this means you'll need to restore write permissions if you'd like +to rebuild an existing release on a new architecture or remove it entirely. + Example command: -"ioc-deploy -n ioc-foo-bar -r R1.0.0" -This will clone the repository to the default ioc directory and run make -using the currently set EPICS environment variables. +"ioc-deploy -n ioc-foo-bar -r R1.0.0" -With default settings this will clone +This will clone the repository to the default ioc directory and run make using the +currently set EPICS environment variables, then apply write protection. +With default settings, this will clone from https://github.com/pcdshub/ioc-foo-bar to /cds/group/pcds/epics/ioc/foo/bar/R1.0.0 +then cd and make and chmod as appropriate. + +The second action will not do any git or make actions, it will only find the +release directory and change the file and directory permissions. +This can be done with similar commands as above, adding the subparser command, +and it can be done by passing the specific path you'd like to modify +if this is more convenient for you. + +Example commands: -then cd and make. +"ioc-deploy update-perms rw -n ioc-foo-bar -r R1.0.0" +"ioc-deploy update-perms ro -n ioc-foo-bar -r R1.0.0" +"ioc-deploy update-perms rw -p /cds/group/pcds/epics/ioc/foo/bar/R1.0.0" +"ioc-deploy update-perms ro -p /cds/group/pcds/epics/ioc/foo/bar/R1.0.0" """ import argparse import enum import logging import os +import os.path +import stat import subprocess import sys from pathlib import Path from tempfile import TemporaryDirectory -from typing import Tuple +from typing import List, Optional, Tuple EPICS_SITE_TOP_DEFAULT = "/cds/group/pcds/epics" GITHUB_ORG_DEFAULT = "pcdshub" +CHMOD_SYMLINKS = os.chmod in os.supports_follow_symlinks +PERMS_CMD = "update-perms" logger = logging.getLogger("ioc-deploy") @@ -47,72 +75,134 @@ class CliArgs: release: str = "" ioc_dir: str = "" github_org: str = "" + path_override: str = "" auto_confirm: bool = False dry_run: bool = False verbose: bool = False version: bool = False + permissions: str = "" + + @dataclasses.dataclass(frozen=True) + class DeployInfo: + """ + Finalized deploy name and release information. + """ + + deploy_dir: str + pkg_name: Optional[str] + rel_name: Optional[str] + else: - from types import SimpleNamespace as CliArgs + from types import SimpleNamespace + + CliArgs = SimpleNamespace + DeployInfo = SimpleNamespace -def get_parser() -> argparse.ArgumentParser: +def get_parser(subparser: bool = False) -> argparse.ArgumentParser: current_default_target = str( Path(os.environ.get("EPICS_SITE_TOP", EPICS_SITE_TOP_DEFAULT)) / "ioc" ) current_default_org = os.environ.get("GITHUB_ORG", GITHUB_ORG_DEFAULT) - parser = argparse.ArgumentParser(prog="ioc-deploy", description=__doc__) - parser.add_argument( + main_parser = argparse.ArgumentParser( + prog="ioc-deploy", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + # main_parser unique arguments that should go first + main_parser.add_argument( "--version", action="store_true", help="Show version number and exit." ) - parser.add_argument( - "--name", - "-n", - action="store", - default="", - help="The name of the repository to deploy. This is a required argument. If it does not exist on github, we'll also try prepending with 'ioc-common-'.", + subparsers = main_parser.add_subparsers(help="Subcommands (will not deploy):") + # perms_parser unique arguments that should go first + perms_parser = subparsers.add_parser( + PERMS_CMD, + help=f"Use 'ioc-deploy {PERMS_CMD}' to update the write permissions of a deployment. See 'ioc-deploy {PERMS_CMD} --help' for more information.", + description="Update the write permissions of a deployment. This will make all the files read-only (ro), or owner and group writable (rw).", ) - parser.add_argument( - "--release", - "-r", - action="store", - default="", - help="The version of the IOC to deploy. This is a required argument.", + perms_parser.add_argument( + "permissions", + choices=("ro", "rw"), + type=force_lower, + help="Select whether to make the deployment permissions read-only (ro) or read-write (rw).", ) - parser.add_argument( - "--ioc-dir", - "-i", - action="store", - default=current_default_target, - help=f"The directory to deploy IOCs in. This defaults to $EPICS_SITE_TOP/ioc, or {EPICS_SITE_TOP_DEFAULT}/ioc if the environment variable is not set. With your current environment variables, this defaults to {current_default_target}.", - ) - parser.add_argument( + # shared arguments + for parser in main_parser, perms_parser: + parser.add_argument( + "--name", + "-n", + action="store", + default="", + help="The name of the repository to deploy. This is a required argument. If it does not exist on github, we'll also try prepending with 'ioc-common-'.", + ) + parser.add_argument( + "--release", + "-r", + action="store", + default="", + help="The version of the IOC to deploy. This is a required argument.", + ) + parser.add_argument( + "--ioc-dir", + "-i", + action="store", + default=current_default_target, + help=f"The directory to deploy IOCs in. This defaults to $EPICS_SITE_TOP/ioc, or {EPICS_SITE_TOP_DEFAULT}/ioc if the environment variable is not set. With your current environment variables, this defaults to {current_default_target}.", + ) + parser.add_argument( + "--path-override", + "-p", + action="store", + help="If provided, ignore all normal path-selection rules in favor of the specific provided path. This will let you deploy IOCs or apply protection rules to arbitrary specific paths.", + ) + parser.add_argument( + "--auto-confirm", + "--confirm", + "--yes", + "-y", + action="store_true", + help="Skip the confirmation promps, automatically saying yes to each one.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Do not deploy anything, just print what would have been done.", + ) + parser.add_argument( + "--verbose", + "-v", + "--debug", + action="store_true", + help="Display additional debug information.", + ) + # main_parser unique arguments that should go last + main_parser.add_argument( "--github_org", "--org", action="store", default=current_default_org, help=f"The github org to deploy IOCs from. This defaults to $GITHUB_ORG, or {GITHUB_ORG_DEFAULT} if the environment variable is not set. With your current environment variables, this defaults to {current_default_org}.", ) - parser.add_argument( - "--auto-confirm", - "--confirm", - "--yes", - "-y", - action="store_true", - help="Skip the confirmation promps, automatically saying yes to each one.", - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Do not deploy anything, just print what would have been done.", - ) - parser.add_argument( - "--verbose", - "-v", - "--debug", - action="store_true", - help="Display additional debug information.", - ) - return parser + if subparser: + return perms_parser + return main_parser + + +def is_yes(option: str, error_on_empty: bool = True) -> bool: + option = option.strip().lower() + if option: + option = option[0] + if option in ("t", "y"): + return True + elif option in ("f", "n"): + return False + if not option and not error_on_empty: + return False + raise ValueError(f"{option} is not a valid argument") + + +def force_lower(text: str) -> str: + return str(text).lower() class ReturnCode(enum.IntEnum): @@ -121,44 +211,37 @@ class ReturnCode(enum.IntEnum): NO_CONFIRM = 2 -def main(args: CliArgs) -> int: +def main_deploy(args: CliArgs) -> int: """ - All main steps of the script. + All main steps of the deploy script. + + This will be called when args has neither of apply_write_protection and remove_write_protection Will either return an int return code or raise. """ - if not (args.name and args.release): - raise ValueError( - "Must provide both --name and --release. Check ioc-deploy --help for usage." + deploy_info = get_deploy_info(args) + deploy_dir = deploy_info.deploy_dir + pkg_name = deploy_info.pkg_name + rel_name = deploy_info.rel_name + + if pkg_name is None or rel_name is None: + logger.error( + f"Something went wrong at package/tag normalization: package {pkg_name} at version {rel_name}" ) + return ReturnCode.EXCEPTION - logger.info("Running ioc-deploy: checking inputs") - upd_name = finalize_name( - name=args.name, - github_org=args.github_org, - ioc_dir=args.ioc_dir, - verbose=args.verbose, - ) - upd_rel = finalize_tag( - name=upd_name, - github_org=args.github_org, - release=args.release, - verbose=args.verbose, - ) - deploy_dir = get_target_dir(name=upd_name, ioc_dir=args.ioc_dir, release=upd_rel) - - logger.info(f"Deploying {args.github_org}/{upd_name} at {upd_rel} to {deploy_dir}") + logger.info(f"Deploying {args.github_org}/{pkg_name} at {rel_name} to {deploy_dir}") if Path(deploy_dir).exists(): raise RuntimeError(f"Deploy directory {deploy_dir} already exists! Aborting.") if not args.auto_confirm: - user_text = input("Confirm release source and target? y/n\n") - if not user_text.strip().lower().startswith("y"): + user_text = input("Confirm release source and target? yes/true or no/false\n") + if not is_yes(user_text, error_on_empty=False): return ReturnCode.NO_CONFIRM logger.info(f"Cloning IOC to {deploy_dir}") rval = clone_repo_tag( - name=upd_name, + name=pkg_name, github_org=args.github_org, - release=upd_rel, + release=rel_name, deploy_dir=deploy_dir, dry_run=args.dry_run, verbose=args.verbose, @@ -172,9 +255,139 @@ def main(args: CliArgs) -> int: if rval != ReturnCode.SUCCESS: logger.error(f"Nonzero return value {rval} from make") return rval + logger.info(f"Applying write protection to {deploy_dir}") + rval = set_permissions( + deploy_dir=deploy_dir, allow_write=False, dry_run=args.dry_run + ) + if rval != ReturnCode.SUCCESS: + logger.error(f"Nonzero return value {rval} from set_permissions") + return rval + logger.info("IOC clone, make, and permission change complete!") return ReturnCode.SUCCESS +def main_perms(args: CliArgs) -> int: + """ + All main steps of the only-apply-permissions script. + + This will be called when args has at least one of apply_write_protection and remove_write_protection + + Will either return an int code or raise. + """ + if args.permissions not in ("ro", "rw"): + logger.error( + f"Entered main_perms with invalid permissions selected {args.permissions}" + ) + return ReturnCode.EXCEPTION + allow_write = args.permissions == "rw" + + deploy_dir = get_perms_target(args) + + if allow_write: + logger.info(f"Allowing writes to {deploy_dir}") + else: + logger.info(f"Preventing writes to {deploy_dir}") + if not args.auto_confirm: + user_text = input("Confirm target? yes/true or no/false\n") + if not is_yes(user_text, error_on_empty=False): + return ReturnCode.NO_CONFIRM + try: + rval = set_permissions( + deploy_dir=deploy_dir, allow_write=allow_write, dry_run=args.dry_run + ) + except OSError as exc: + logger.error(f"OSError during chmod: {exc}") + error_path = Path(exc.filename) + logger.error( + f"Please contact file owner {error_path.owner()} or someone with sudo permissions if you'd like to change the permissions here." + ) + if allow_write: + suggest = "ug+w" + else: + suggest = "a-w" + logger.error( + f"For example, you might try 'sudo chmod -R {suggest} {deploy_dir}' from a server you have sudo access on." + ) + return ReturnCode.EXCEPTION + + if rval == ReturnCode.SUCCESS: + logger.info("Write protection change complete!") + return rval + + +def get_deploy_info(args: CliArgs) -> DeployInfo: + """ + Normalize user inputs and figure out where to deploy to. + + Returns the following in a dataclass: + - The deploy dir (deploy_dir) + - A normalized package name if applicable, or None (pkg_name) + - A normalized tag name if applicable, or None (rel_name) + """ + if args.name and args.github_org: + pkg_name = finalize_name( + name=args.name, + github_org=args.github_org, + ioc_dir=args.ioc_dir, + verbose=args.verbose, + ) + else: + pkg_name = None + if pkg_name and args.github_org and args.release: + rel_name = finalize_tag( + name=pkg_name, + github_org=args.github_org, + release=args.release, + verbose=args.verbose, + ) + else: + rel_name = None + if args.path_override: + deploy_dir = args.path_override + else: + deploy_dir = get_target_dir( + name=pkg_name, ioc_dir=args.ioc_dir, release=rel_name + ) + + return DeployInfo(deploy_dir=deploy_dir, pkg_name=pkg_name, rel_name=rel_name) + + +def get_perms_target(args: CliArgs) -> str: + """ + Normalize user inputs and figure out which directory to modify. + + Unlike get_deploy_info, this will not check github. + Instead, we'll check local filepaths for existing targets. + + This is implemented separately to remove the network dependencies, + but it uses the same helper functions. + """ + if args.path_override: + return args.path_override + _, area, suffix = split_ioc_name(args.name) + area = find_casing_in_dir(dir=args.ioc_dir, name=area) + suffix = find_casing_in_dir(dir=str(Path(args.ioc_dir) / area), name=suffix) + full_name = "-".join(("ioc", area, suffix)) + try_release = release_permutations(args.release) + for release in try_release: + target = get_target_dir(name=full_name, ioc_dir=args.ioc_dir, release=release) + if Path(target).is_dir(): + return target + raise RuntimeError("Unable to find existing release matching the inputs.") + + +def find_casing_in_dir(dir: str, name: str) -> str: + """ + Find a file or directory in dir that matches name aside from casing. + + Raises a RuntimeError if nothing could be found. + """ + for path in Path(dir).iterdir(): + if path.name.lower() == name.lower(): + return path.name + raise RuntimeError(f"Did not find {name} in {dir}") + + def finalize_name(name: str, github_org: str, ioc_dir: str, verbose: bool) -> str: """ Check if name is present in org, is well-formed, and has correct casing. @@ -233,30 +446,23 @@ def finalize_name(name: str, github_org: str, ioc_dir: str, verbose: bool) -> st logger.info(f"{repo_dir} does not exist, checking for other casings") _, area, suffix = split_ioc_name(name) # First, check for casing on area - found_area = False - for path in Path(ioc_dir).iterdir(): - if path.name.lower() == area.lower(): - area = path.name - found_area = True - logger.info(f"Using {area} as the area") - break - if not found_area: + try: + area = find_casing_in_dir(dir=ioc_dir, name=area) + except RuntimeError: logger.info("This is a new area, checking readme for casing") name = casing_from_readme(name=name, readme_text=readme_text) logger.info(f"Using casing: {name}") return name + logger.info(f"Using {area} as the area") - found_suffix = False - for path in (Path(ioc_dir) / area).iterdir(): - if path.name.lower() == suffix.lower(): - suffix = path.name - found_suffix = True - logger.info(f"Using {suffix} as the name") - break - if not found_suffix: + try: + suffix = find_casing_in_dir(dir=str(Path(ioc_dir) / area), name=suffix) + except RuntimeError: logger.info("This is a new ioc, checking readme for casing") # Use suffix from readme but keep area from directory search - suffix = split_ioc_name(casing_from_readme(name=name, readme_text=readme_text))[2] + casing = casing_from_readme(name=name, readme_text=readme_text) + suffix = split_ioc_name(casing)[2] + logger.info(f"Using {suffix} as the name") name = "-".join(("ioc", area, suffix)) logger.info(f"Using casing: {name}") @@ -318,15 +524,7 @@ def finalize_tag(name: str, github_org: str, release: str, verbose: bool) -> str - v1.0.0 - 1.0.0 """ - try_release = [release] - if release.startswith("R"): - try_release.extend([f"v{release[1:]}", f"{release[1:]}"]) - elif release.startswith("v"): - try_release.extend([f"R{release[1:]}", f"{release[1:]}"]) - elif release[0].isalpha(): - try_release.extend([f"R{release[1:]}", f"v{release[1:]}", f"{release[1:]}"]) - else: - try_release.extend([f"R{release}", f"v{release}"]) + try_release = release_permutations(release=release) with TemporaryDirectory() as tmpdir: for rel in try_release: @@ -348,6 +546,22 @@ def finalize_tag(name: str, github_org: str, release: str, verbose: bool) -> str raise ValueError(f"Unable to find {release} in {github_org}/{name}") +def release_permutations(release: str) -> List[str]: + """ + Given a user-provided tag/release name, return all the variants we'd like to check for. + """ + try_release = [release] + if release.startswith("R"): + try_release.extend([f"v{release[1:]}", f"{release[1:]}"]) + elif release.startswith("v"): + try_release.extend([f"R{release[1:]}", f"{release[1:]}"]) + elif release[0].isalpha(): + try_release.extend([f"R{release[1:]}", f"v{release[1:]}", f"{release[1:]}"]) + else: + try_release.extend([f"R{release}", f"v{release}"]) + return try_release + + def get_target_dir(name: str, ioc_dir: str, release: str) -> str: """ Return the directory we'll deploy the IOC in. @@ -373,7 +587,7 @@ def clone_repo_tag( # Make sure the parent dir exists parent_dir = Path(deploy_dir).resolve().parent if dry_run: - logger.info(f"Dry-run: make {parent_dir} if not existing.") + logger.info(f"Dry-run: make {parent_dir} if not existing") else: logger.debug(f"Ensure {parent_dir} exists") parent_dir.mkdir(parents=True, exist_ok=True) @@ -402,6 +616,63 @@ def make_in(deploy_dir: str, dry_run: bool) -> int: return subprocess.run(["make"], cwd=deploy_dir).returncode +def set_permissions(deploy_dir: str, allow_write: bool, dry_run: bool) -> int: + """ + Apply or remove write permissions from a deploy repo. + + allow_write=True involves adding "w" permissions to all files and directories + for the owner and for the group. "w" permissions will never be added for other users. + We will also add write permissions to the top-level directory. + + allow_write=False involves removing the "w" permissions from all files and directories + for the owner, group, and other users. + We will also remove write permissions from the top-level direcotry. + """ + if dry_run and not os.path.isdir(deploy_dir): + # Dry run has nothing to do if we didn't build the dir + # Most things past this point will error out + logger.info("Dry-run: skipping permission changes on never-made directory") + return ReturnCode.SUCCESS + + set_one_permission(deploy_dir, allow_write=allow_write, dry_run=dry_run) + + for dirpath, dirnames, filenames in os.walk(deploy_dir): + for name in dirnames + filenames: + full_path = os.path.join(dirpath, name) + set_one_permission(full_path, allow_write=allow_write, dry_run=dry_run) + + return ReturnCode.SUCCESS + + +def set_one_permission(path: str, allow_write: bool, dry_run: bool) -> None: + """ + Given some file, adjust the permissions as needed for this script. + + If allow_write is True, allow owner and group writes. + If allow_write is False, prevent all writes. + + During a dry run, log what would be changed at info level without + making any changes. This log will be present at debug level + for verbose mode during real changes. + """ + if os.path.islink(path) and not CHMOD_SYMLINKS: + logger.debug(f"Skip {path}, os doesn't support follow_symlinks in chmod.") + return + mode = os.stat(path, follow_symlinks=False).st_mode + if allow_write: + new_mode = mode | stat.S_IWUSR | stat.S_IWGRP + else: + new_mode = mode & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH) + if dry_run: + logger.info(f"Dry-run: would change {path} from {oct(mode)} to {oct(new_mode)}") + else: + logger.debug(f"Changing {path} from {oct(mode)} to {oct(new_mode)}") + if CHMOD_SYMLINKS: + os.chmod(path, new_mode, follow_symlinks=False) + else: + os.chmod(path, new_mode) + + def get_version() -> str: """ Determine what version of engineering_tools is being used @@ -449,10 +720,57 @@ def _clone( return subprocess.run(cmd, **kwds) +def print_help_text_for_readme(): + """ + Prints a text blob for me to paste into the release notes table. + """ + text = get_parser().format_help() + "\n" + get_parser(subparser=True).format_help() + lines = text.splitlines() + output = [] + for line in lines: + if not line: + output.append(" ") + elif line[0] == " ": + output.append(" " + line[1:]) + else: + output.append(line) + print("\n".join(output)) + + +def rearrange_sys_argv_for_subcommands(): + """ + Small trick to help argparse deal with my optional subcommand. + + This will make argv like this: + ioc-deploy -p some_path perms ro + be interpretted the same as: + ioc-deploy perms ro -p some_path + + Otherwise, the first example here is interpretted as if -p was never passed, + which could be confusing. + """ + try: + perms_index = sys.argv.index(PERMS_CMD) + except ValueError: + return + if perms_index == 1: + return + subcmd = sys.argv[perms_index] + try: + mode = sys.argv[perms_index + 1] + except IndexError: + return + sys.argv.remove(subcmd) + sys.argv.remove(mode) + sys.argv.insert(1, subcmd) + sys.argv.insert(2, mode) + + def _main() -> int: """ Thin wrapper of main() for log setup, args handling, and high-level error handling. """ + rearrange_sys_argv_for_subcommands() args = CliArgs(**vars(get_parser().parse_args())) if args.verbose: level = logging.DEBUG @@ -466,11 +784,33 @@ def _main() -> int: if args.version: print(get_version()) return ReturnCode.SUCCESS - return main(args) + logger.info("ioc-deploy: checking inputs") + if not (args.name and args.release) and not args.path_override: + logger.error( + "Must provide both --name and --release, or --path-override. Check ioc-deploy --help for usage." + ) + return ReturnCode.EXCEPTION + try: + do_perms_cmd = args.permissions + except AttributeError: + do_perms_cmd = False + if do_perms_cmd: + rval = main_perms(args) + else: + rval = main_deploy(args) + except Exception as exc: logger.error(exc) logger.debug("Traceback", exc_info=True) - return ReturnCode.EXCEPTION + rval = ReturnCode.EXCEPTION + + if rval == ReturnCode.SUCCESS: + logger.info("ioc-deploy completed successfully") + elif rval == ReturnCode.EXCEPTION: + logger.error("ioc-deploy errored out") + elif rval == ReturnCode.NO_CONFIRM: + logger.info("ioc-deploy cancelled") + return rval if __name__ == "__main__":