From f141942814ced9275965780ddbec9d8ef9701e41 Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Tue, 27 Aug 2024 18:43:56 -0700 Subject: [PATCH 01/35] ENH: add permission handling to ioc-deploy --- scripts/ioc_deploy.py | 135 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 129 insertions(+), 6 deletions(-) diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index ccc656b8..db66aede 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -3,25 +3,30 @@ 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. +After making the IOC, we'll write-protect all files and all directories that contain built files +(e.g. those that contain files that are not tracked in git). +We'll also write-protect the top-level directory to help indicate completion. 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. +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. +then cd and make and chmod as appropriate. """ import argparse import enum import logging import os +import os.path +import stat import subprocess import sys from pathlib import Path @@ -46,6 +51,8 @@ class CliArgs: release: str = "" ioc_dir: str = "" github_org: str = "" + remove_write_protection: bool = False, + apply_write_protection: bool = False, auto_confirm: bool = False dry_run: bool = False verbose: bool = False @@ -91,6 +98,16 @@ def get_parser() -> argparse.ArgumentParser: 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( + "--remove-write-protection", + action="store_true", + help="If provided, instead of doing a release, we will remove write protection from an existing release. Incompatible with --apply-write-protection." + ) + parser.add_argument( + "--apply-write-protection", + action="store_true", + help="If provided, instead of doing a release, we will add write protection to an existing release. Incompatible with --remove-write-protection." + ) parser.add_argument( "--auto-confirm", "--confirm", @@ -126,12 +143,17 @@ def main(args: CliArgs) -> int: Will either return an int return code or raise. """ + logger.info("Running ioc-deploy: checking inputs") + has_input_error = False if not (args.name and args.release): - raise ValueError( - "Must provide both --name and --release. Check ioc-deploy --help for usage." - ) + logger.error("Must provide both --name and --release. Check ioc-deploy --help for usage.") + has_input_error = True + if args.apply_write_protection and args.remove_write_protection: + logger.error("Must provide at most one of --apply-write-protection and --remove-write-protection") + has_input_error = True + if has_input_error: + return ReturnCode.EXCEPTION - logger.info("Running ioc-deploy: checking inputs") upd_name = finalize_name( name=args.name, github_org=args.github_org, verbose=args.verbose ) @@ -143,6 +165,23 @@ def main(args: CliArgs) -> int: ) deploy_dir = get_target_dir(name=upd_name, ioc_dir=args.ioc_dir, release=upd_rel) + # Branch off for permission modification options + if args.apply_write_protection: + logger.info(f"Applying write permissions to {deploy_dir}") + if not args.auto_confirm: + user_text = input("Confirm target? y/n\n") + if not user_text.strip().lower().startswith("y"): + return ReturnCode.NO_CONFIRM + return set_permissions(deploy_dir=deploy_dir, protect=True, dry_run=args.dry_run) + elif args.remove_write_protection: + logger.info(f"Removing write permissions from {deploy_dir}") + if not args.auto_confirm: + user_text = input("Confirm target? y/n\n") + if not user_text.strip().lower().startswith("y"): + return ReturnCode.NO_CONFIRM + return set_permissions(deploy_dir=deploy_dir, protect=False, dry_run=args.dry_run) + + # Main deploy routines logger.info(f"Deploying {args.github_org}/{upd_name} at {upd_rel} to {deploy_dir}") if Path(deploy_dir).exists(): raise RuntimeError(f"Deploy directory {deploy_dir} already exists! Aborting.") @@ -168,6 +207,10 @@ def main(args: CliArgs) -> int: if rval != ReturnCode.SUCCESS: logger.error(f"Nonzero return value {rval} from make") return rval + rval = set_permissions(deploy_dir=deploy_dir, protect=True, dry_run=args.dry_run) + if rval != ReturnCode.SUCCESS: + logger.error(f"Nonzero return value {rval} from set_permissions") + return rval return ReturnCode.SUCCESS @@ -301,6 +344,86 @@ def make_in(deploy_dir: str, dry_run: bool) -> int: return subprocess.run(["make"], cwd=deploy_dir).returncode +def set_permissions(deploy_dir: str, protect: bool, dry_run: bool) -> int: + """ + Apply or remove write protection from a deploy repo. + + You may or may not have permissions to do this to releases created by other users. + + Applying write permissions (protect=True) involves removing the "w" permissions + from all files, and from directories that contain untracked files. + We will also remove permissions from the top-level direcotry. + + Removing write permissions (protect=False) involves adding "w" permissions + 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. + """ + if not protect: + # Lazy and simple: chmod everything + perms = get_add_write_rule(os.stat(deploy_dir, follow_symlinks=False).st_mode) + os.chmod(deploy_dir, perms) + for dirpath, dirnames, filenames in os.walk(deploy_dir): + for name in dirnames + filenames: + full_path = os.path.join(dirpath, name) + perms = get_add_write_rule(os.stat(full_path, follow_symlinks=False).st_mode) + os.chmod(full_path, perms) + return ReturnCode.SUCCESS + + # Compare the files that exist to the files that are tracked by git + try: + ls_files_output = subprocess.check_output( + ["git", "-C", deploy_dir, "ls-files"], + universal_newlines=True, + ) + except subprocess.CalledProcessError as exc: + return exc.returncode + deploy_path = Path(deploy_dir) + tracked_paths = [deploy_path / subpath.strip() for subpath in ls_files_output.splitlines()] + build_dir_paths = set() + + def accumulate_subpaths(subpath: Path): + for path in subpath.iterdir(): + if path.is_symlink(): + continue + elif path.is_dir(): + accumulate_subpaths(path) + elif path.is_file(): + if path not in tracked_paths: + build_dir_paths.add(str(path.parent)) + + accumulate_subpaths(deploy_path) + + # Follow the write protection rules from the docstring + perms = get_remove_write_rule(os.stat(deploy_dir, follow_symlinks=False).st_mode) + os.chmod(deploy_dir, perms) + for dirpath, dirnames, filenames in os.walk(deploy_dir): + for dirn in dirnames: + if dirn in build_dir_paths: + full_path = os.path.join(dirpath, dirn) + perms = get_remove_write_rule(os.stat(full_path, follow_symlinks=False).st_mode) + os.chmod(full_path, perms) + for filn in filenames: + full_path = os.path.join(dirpath, filn) + perms = get_remove_write_rule(os.stat(full_path, follow_symlinks=False).st_mode) + os.chmod(full_path, perms) + + return ReturnCode.SUCCESS + + +def get_remove_write_rule(perms: int) -> int: + """ + Given some existing file permissions, return the same permissions with no writes permitted. + """ + return perms ^ stat.S_IWUSR ^ stat.S_IWGRP ^ stat.S_IWOTH + + +def get_add_write_rule(perms: int) -> int: + """ + Given some existing file permissions, return the same permissions with user and group writes permitted. + """ + return perms | stat.S_IWUSR | stat.S_IWGRP + + def get_version() -> str: """ Determine what version of engineering_tools is being used From 0538fb91b78b07acea86d0f1741bf796ec5354ca Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Thu, 29 Aug 2024 13:58:08 -0700 Subject: [PATCH 02/35] ENH/REF: add a full path override for convenience/testing and refactor a bit --- scripts/ioc_deploy.py | 119 +++++++++++++++++++++++++++++------------- 1 file changed, 84 insertions(+), 35 deletions(-) diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index db66aede..c75df76d 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -53,6 +53,7 @@ class CliArgs: github_org: str = "" remove_write_protection: bool = False, apply_write_protection: bool = False, + path_override: str = "" auto_confirm: bool = False dry_run: bool = False verbose: bool = False @@ -108,6 +109,11 @@ def get_parser() -> argparse.ArgumentParser: action="store_true", help="If provided, instead of doing a release, we will add write protection to an existing release. Incompatible with --remove-write-protection." ) + parser.add_argument( + "--path-override", + 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 outside of normal deployment directory structures.", + ) parser.add_argument( "--auto-confirm", "--confirm", @@ -137,51 +143,25 @@ 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. """ logger.info("Running ioc-deploy: checking inputs") - has_input_error = False if not (args.name and args.release): logger.error("Must provide both --name and --release. Check ioc-deploy --help for usage.") - has_input_error = True - if args.apply_write_protection and args.remove_write_protection: - logger.error("Must provide at most one of --apply-write-protection and --remove-write-protection") - has_input_error = True - if has_input_error: return ReturnCode.EXCEPTION - upd_name = finalize_name( - name=args.name, github_org=args.github_org, 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) + deploy_dir, upd_name, upd_rel = pick_deploy_dir(args) - # Branch off for permission modification options - if args.apply_write_protection: - logger.info(f"Applying write permissions to {deploy_dir}") - if not args.auto_confirm: - user_text = input("Confirm target? y/n\n") - if not user_text.strip().lower().startswith("y"): - return ReturnCode.NO_CONFIRM - return set_permissions(deploy_dir=deploy_dir, protect=True, dry_run=args.dry_run) - elif args.remove_write_protection: - logger.info(f"Removing write permissions from {deploy_dir}") - if not args.auto_confirm: - user_text = input("Confirm target? y/n\n") - if not user_text.strip().lower().startswith("y"): - return ReturnCode.NO_CONFIRM - return set_permissions(deploy_dir=deploy_dir, protect=False, dry_run=args.dry_run) + if upd_name is None or upd_rel is None: + logger.error(f"Something went wrong at package/tag normalization: {upd_name}@{upd_rel}") + return ReturnCode.EXCEPTION - # Main deploy routines logger.info(f"Deploying {args.github_org}/{upd_name} at {upd_rel} to {deploy_dir}") if Path(deploy_dir).exists(): raise RuntimeError(f"Deploy directory {deploy_dir} already exists! Aborting.") @@ -214,6 +194,72 @@ def main(args: CliArgs) -> int: 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. + """ + logger.info("Running ioc-deploy write protection change: checking inputs") + if args.apply_write_protection and args.remove_write_protection: + logger.error("Must provide at most one of --apply-write-protection and --remove-write-protection") + return ReturnCode.EXCEPTION + + deploy_dir, _, _ = pick_deploy_dir(args) + + if args.apply_write_protection: + logger.info(f"Applying write permissions to {deploy_dir}") + if not args.auto_confirm: + user_text = input("Confirm target? y/n\n") + if not user_text.strip().lower().startswith("y"): + return ReturnCode.NO_CONFIRM + return set_permissions(deploy_dir=deploy_dir, protect=True, dry_run=args.dry_run) + elif args.remove_write_protection: + logger.info(f"Removing write permissions from {deploy_dir}") + if not args.auto_confirm: + user_text = input("Confirm target? y/n\n") + if not user_text.strip().lower().startswith("y"): + return ReturnCode.NO_CONFIRM + return set_permissions(deploy_dir=deploy_dir, protect=False, dry_run=args.dry_run) + + logger.error("Invalid codepath, how did you get here? Submit a bug report please.") + return ReturnCode.EXCEPTION + + +def pick_deploy_dir(args: CliArgs) -> tuple[str, str | None, str | None]: + """ + Normalize user inputs and figure out where to deploy to. + + Returns a tuple of three elements: + - The deploy dir + - A normalized package name if applicable, or None + - A normalized tag name if applicable, or None + """ + if args.name and args.github_org: + upd_name = finalize_name( + name=args.name, github_org=args.github_org, verbose=args.verbose + ) + else: + upd_name = None + if upd_name and args.github_org and args.release: + upd_rel = finalize_tag( + name=upd_name, + github_org=args.github_org, + release=args.release, + verbose=args.verbose, + ) + else: + upd_rel = None + if args.path_override: + deploy_dir = args.path_override + else: + deploy_dir = get_target_dir(name=upd_name, ioc_dir=args.ioc_dir, release=upd_rel) + + return deploy_dir, upd_name, upd_rel + + def finalize_name(name: str, github_org: str, verbose: bool) -> str: """ Check if name is present in org and is well-formed. @@ -488,7 +534,10 @@ def _main() -> int: if args.version: print(get_version()) return ReturnCode.SUCCESS - return main(args) + if args.apply_write_protection or args.remove_write_protection: + return main_perms(args) + else: + return main_deploy(args) except Exception as exc: logger.error(exc) logger.debug("Traceback", exc_info=True) From 1786fa92c62ed3a741ebe27040d205d6852f5303 Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Thu, 29 Aug 2024 14:11:01 -0700 Subject: [PATCH 03/35] DOC: nitpick error message --- scripts/ioc_deploy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index c75df76d..dde17289 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -159,7 +159,7 @@ def main_deploy(args: CliArgs) -> int: deploy_dir, upd_name, upd_rel = pick_deploy_dir(args) if upd_name is None or upd_rel is None: - logger.error(f"Something went wrong at package/tag normalization: {upd_name}@{upd_rel}") + logger.error(f"Something went wrong at package/tag normalization: package {upd_name} at version {upd_rel}") return ReturnCode.EXCEPTION logger.info(f"Deploying {args.github_org}/{upd_name} at {upd_rel} to {deploy_dir}") From 8a4cabfdf05ac832ea51760130dd8794077bc82f Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Thu, 29 Aug 2024 14:12:00 -0700 Subject: [PATCH 04/35] FIX: future style annotations fix --- scripts/ioc_deploy.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index dde17289..20312f2f 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -21,6 +21,8 @@ then cd and make and chmod as appropriate. """ +from __future__ import annotations + import argparse import enum import logging From 51dcbe1aad0d349939529a198fe03ee0a02cf9b5 Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Thu, 29 Aug 2024 14:13:39 -0700 Subject: [PATCH 05/35] DOC: nitpick help text --- scripts/ioc_deploy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index 20312f2f..ecfc2acd 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -114,7 +114,7 @@ def get_parser() -> argparse.ArgumentParser: parser.add_argument( "--path-override", 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 outside of normal deployment directory structures.", + 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", From 15e239a9ce5e958e3d070deeb402a3b69ec3afc9 Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Thu, 29 Aug 2024 14:39:40 -0700 Subject: [PATCH 06/35] MAINT: py36 compat --- scripts/ioc_deploy.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index ecfc2acd..3e99c99d 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -20,9 +20,6 @@ then cd and make and chmod as appropriate. """ - -from __future__ import annotations - import argparse import enum import logging @@ -33,6 +30,7 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory +from typing import Optional, Tuple EPICS_SITE_TOP_DEFAULT = "/cds/group/pcds/epics" GITHUB_ORG_DEFAULT = "pcdshub" @@ -53,8 +51,8 @@ class CliArgs: release: str = "" ioc_dir: str = "" github_org: str = "" - remove_write_protection: bool = False, - apply_write_protection: bool = False, + remove_write_protection: bool = False + apply_write_protection: bool = False path_override: str = "" auto_confirm: bool = False dry_run: bool = False @@ -230,7 +228,7 @@ def main_perms(args: CliArgs) -> int: return ReturnCode.EXCEPTION -def pick_deploy_dir(args: CliArgs) -> tuple[str, str | None, str | None]: +def pick_deploy_dir(args: CliArgs) -> Tuple[str, Optional[str], Optional[str]]: """ Normalize user inputs and figure out where to deploy to. From 1e3370b7d3495dd2e17d89994450fcca6cd9eda5 Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Thu, 29 Aug 2024 14:54:04 -0700 Subject: [PATCH 07/35] ENH: implement dry run on permissions changes --- scripts/ioc_deploy.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index 3e99c99d..a34b1a8a 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -407,12 +407,25 @@ def set_permissions(deploy_dir: str, protect: bool, dry_run: bool) -> int: if not protect: # Lazy and simple: chmod everything perms = get_add_write_rule(os.stat(deploy_dir, follow_symlinks=False).st_mode) - os.chmod(deploy_dir, perms) + if dry_run: + logger.info(f"Dry-run: skipping chmod({deploy_dir}, {perms})") + else: + os.chmod(deploy_dir, perms) for dirpath, dirnames, filenames in os.walk(deploy_dir): for name in dirnames + filenames: full_path = os.path.join(dirpath, name) perms = get_add_write_rule(os.stat(full_path, follow_symlinks=False).st_mode) - os.chmod(full_path, perms) + if dry_run: + logger.debug(f"Dry-run: skipping chmod({full_path}, {perms})") + else: + logger.debug(f"chmod({full_path}, {perms})") + os.chmod(full_path, perms) + return ReturnCode.SUCCESS + + if dry_run and not os.path.isdir(deploy_dir): + # Dry run has nothing more to do if we didn't build the dir + # Everything past this point will error out + logger.info("Dry-run: skipping permission changes on never-made directory") return ReturnCode.SUCCESS # Compare the files that exist to the files that are tracked by git @@ -441,17 +454,26 @@ def accumulate_subpaths(subpath: Path): # Follow the write protection rules from the docstring perms = get_remove_write_rule(os.stat(deploy_dir, follow_symlinks=False).st_mode) - os.chmod(deploy_dir, perms) + if dry_run: + logger.info(f"Dry-run: skipping chmod({deploy_dir}, {perms})") + else: + os.chmod(deploy_dir, perms) for dirpath, dirnames, filenames in os.walk(deploy_dir): for dirn in dirnames: if dirn in build_dir_paths: full_path = os.path.join(dirpath, dirn) perms = get_remove_write_rule(os.stat(full_path, follow_symlinks=False).st_mode) - os.chmod(full_path, perms) + if dry_run: + logger.debug(f"Dry-run: skipping chmod({full_path}, {perms})") + else: + os.chmod(full_path, perms) for filn in filenames: full_path = os.path.join(dirpath, filn) perms = get_remove_write_rule(os.stat(full_path, follow_symlinks=False).st_mode) - os.chmod(full_path, perms) + if dry_run: + logger.debug(f"Dry-run: skipping chmod({full_path}, {perms})") + else: + os.chmod(full_path, perms) return ReturnCode.SUCCESS From a42de3f5fefc88deaefd928fb0a3839683e280ca Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Thu, 29 Aug 2024 14:54:54 -0700 Subject: [PATCH 08/35] MAINT: move up dry-run no dir catch --- scripts/ioc_deploy.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index a34b1a8a..371de157 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -404,6 +404,12 @@ def set_permissions(deploy_dir: str, protect: bool, dry_run: bool) -> int: 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. """ + 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 + if not protect: # Lazy and simple: chmod everything perms = get_add_write_rule(os.stat(deploy_dir, follow_symlinks=False).st_mode) @@ -422,12 +428,6 @@ def set_permissions(deploy_dir: str, protect: bool, dry_run: bool) -> int: os.chmod(full_path, perms) return ReturnCode.SUCCESS - if dry_run and not os.path.isdir(deploy_dir): - # Dry run has nothing more to do if we didn't build the dir - # Everything past this point will error out - logger.info("Dry-run: skipping permission changes on never-made directory") - return ReturnCode.SUCCESS - # Compare the files that exist to the files that are tracked by git try: ls_files_output = subprocess.check_output( From 3e1a6ca33b14112742b0b976777a5a7112af314d Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Fri, 30 Aug 2024 15:26:13 -0700 Subject: [PATCH 09/35] DOC: typo in docstring --- scripts/ioc_deploy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index 371de157..c18ef2f0 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -396,11 +396,11 @@ def set_permissions(deploy_dir: str, protect: bool, dry_run: bool) -> int: You may or may not have permissions to do this to releases created by other users. - Applying write permissions (protect=True) involves removing the "w" permissions + Applying write protection (protect=True) involves removing the "w" permissions from all files, and from directories that contain untracked files. We will also remove permissions from the top-level direcotry. - Removing write permissions (protect=False) involves adding "w" permissions + Removing write protection (protect=False) involves adding "w" permissions 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. """ From 9b303871bd6c2adc72cbf7a2093a0a59b5243578 Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Fri, 30 Aug 2024 15:32:05 -0700 Subject: [PATCH 10/35] ENH: tell the user that we're applying write permissions and when we're done --- scripts/ioc_deploy.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index c18ef2f0..8354dd29 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -187,10 +187,12 @@ def main_deploy(args: CliArgs) -> int: if rval != ReturnCode.SUCCESS: logger.error(f"Nonzero return value {rval} from make") return rval + logger.info(f"Applying write permissions to {deploy_dir}") rval = set_permissions(deploy_dir=deploy_dir, protect=True, dry_run=args.dry_run) if rval != ReturnCode.SUCCESS: logger.error(f"Nonzero return value {rval} from set_permissions") return rval + logger.info("ioc-deploy complete!") return ReturnCode.SUCCESS From 998c790865ace1d7fbcf20156933a825ea71f865 Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Fri, 30 Aug 2024 16:00:16 -0700 Subject: [PATCH 11/35] ENH: fix various issues and improve text output --- scripts/ioc_deploy.py | 43 +++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index 8354dd29..3d45a06d 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -187,7 +187,7 @@ def main_deploy(args: CliArgs) -> int: if rval != ReturnCode.SUCCESS: logger.error(f"Nonzero return value {rval} from make") return rval - logger.info(f"Applying write permissions to {deploy_dir}") + logger.info(f"Applying write protection to {deploy_dir}") rval = set_permissions(deploy_dir=deploy_dir, protect=True, dry_run=args.dry_run) if rval != ReturnCode.SUCCESS: logger.error(f"Nonzero return value {rval} from set_permissions") @@ -211,23 +211,29 @@ def main_perms(args: CliArgs) -> int: deploy_dir, _, _ = pick_deploy_dir(args) + rval = None if args.apply_write_protection: - logger.info(f"Applying write permissions to {deploy_dir}") + logger.info(f"Applying write protection to {deploy_dir}") if not args.auto_confirm: user_text = input("Confirm target? y/n\n") if not user_text.strip().lower().startswith("y"): return ReturnCode.NO_CONFIRM - return set_permissions(deploy_dir=deploy_dir, protect=True, dry_run=args.dry_run) + rval = set_permissions(deploy_dir=deploy_dir, protect=True, dry_run=args.dry_run) elif args.remove_write_protection: - logger.info(f"Removing write permissions from {deploy_dir}") + logger.info(f"Removing write protection from {deploy_dir}") if not args.auto_confirm: user_text = input("Confirm target? y/n\n") if not user_text.strip().lower().startswith("y"): return ReturnCode.NO_CONFIRM - return set_permissions(deploy_dir=deploy_dir, protect=False, dry_run=args.dry_run) + rval = set_permissions(deploy_dir=deploy_dir, protect=False, dry_run=args.dry_run) - logger.error("Invalid codepath, how did you get here? Submit a bug report please.") - return ReturnCode.EXCEPTION + if rval == ReturnCode.SUCCESS: + logger.info("Write protection change complete!") + elif rval is None: + logger.error("Invalid codepath, how did you get here? Submit a bug report please.") + return ReturnCode.EXCEPTION + + return rval def pick_deploy_dir(args: CliArgs) -> Tuple[str, Optional[str], Optional[str]]: @@ -416,7 +422,7 @@ def set_permissions(deploy_dir: str, protect: bool, dry_run: bool) -> int: # Lazy and simple: chmod everything perms = get_add_write_rule(os.stat(deploy_dir, follow_symlinks=False).st_mode) if dry_run: - logger.info(f"Dry-run: skipping chmod({deploy_dir}, {perms})") + logger.info(f"Dry-run: skipping chmod({deploy_dir}, {oct(perms)})") else: os.chmod(deploy_dir, perms) for dirpath, dirnames, filenames in os.walk(deploy_dir): @@ -424,9 +430,9 @@ def set_permissions(deploy_dir: str, protect: bool, dry_run: bool) -> int: full_path = os.path.join(dirpath, name) perms = get_add_write_rule(os.stat(full_path, follow_symlinks=False).st_mode) if dry_run: - logger.debug(f"Dry-run: skipping chmod({full_path}, {perms})") + logger.debug(f"Dry-run: skipping chmod({full_path}, {oct(perms)})") else: - logger.debug(f"chmod({full_path}, {perms})") + logger.debug(f"chmod({full_path}, {oct(perms)})") os.chmod(full_path, perms) return ReturnCode.SUCCESS @@ -453,28 +459,33 @@ def accumulate_subpaths(subpath: Path): build_dir_paths.add(str(path.parent)) accumulate_subpaths(deploy_path) + logger.debug(f"Discovered build dir paths {build_dir_paths}") # Follow the write protection rules from the docstring perms = get_remove_write_rule(os.stat(deploy_dir, follow_symlinks=False).st_mode) if dry_run: - logger.info(f"Dry-run: skipping chmod({deploy_dir}, {perms})") + logger.info(f"Dry-run: skipping chmod({deploy_dir}, {oct(perms)})") else: os.chmod(deploy_dir, perms) for dirpath, dirnames, filenames in os.walk(deploy_dir): for dirn in dirnames: - if dirn in build_dir_paths: - full_path = os.path.join(dirpath, dirn) + full_path = os.path.join(dirpath, dirn) + if full_path in build_dir_paths: perms = get_remove_write_rule(os.stat(full_path, follow_symlinks=False).st_mode) if dry_run: - logger.debug(f"Dry-run: skipping chmod({full_path}, {perms})") + logger.debug(f"Dry-run: skipping chmod({full_path}, {oct(perms)})") else: + logger.debug(f"chmod({full_path}, {oct(perms)})") os.chmod(full_path, perms) + else: + logger.debug(f"Skip directory perms on {full_path}, not in build dir paths") for filn in filenames: full_path = os.path.join(dirpath, filn) perms = get_remove_write_rule(os.stat(full_path, follow_symlinks=False).st_mode) if dry_run: - logger.debug(f"Dry-run: skipping chmod({full_path}, {perms})") + logger.debug(f"Dry-run: skipping chmod({full_path}, {oct(perms)})") else: + logger.debug(f"chmod({full_path}, {oct(perms)})") os.chmod(full_path, perms) return ReturnCode.SUCCESS @@ -484,7 +495,7 @@ def get_remove_write_rule(perms: int) -> int: """ Given some existing file permissions, return the same permissions with no writes permitted. """ - return perms ^ stat.S_IWUSR ^ stat.S_IWGRP ^ stat.S_IWOTH + return perms & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH) def get_add_write_rule(perms: int) -> int: From 05c0c0ca738edb39959dda0f9f21f124869b2186 Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Fri, 30 Aug 2024 16:01:24 -0700 Subject: [PATCH 12/35] MAINT: add basic python gitignore --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .gitignore 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 +*~ From d96aa5aefa0a1d4c4f689474c5ec9bbaef8bbaa5 Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Fri, 30 Aug 2024 17:12:59 -0700 Subject: [PATCH 13/35] ENH: avoid modifying permissions in .git --- scripts/ioc_deploy.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index 3d45a06d..3d2d153c 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -30,7 +30,7 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory -from typing import Optional, Tuple +from typing import Iterator, List, Optional, Tuple EPICS_SITE_TOP_DEFAULT = "/cds/group/pcds/epics" GITHUB_ORG_DEFAULT = "pcdshub" @@ -418,6 +418,9 @@ def set_permissions(deploy_dir: str, protect: bool, dry_run: bool) -> int: logger.info("Dry-run: skipping permission changes on never-made directory") return ReturnCode.SUCCESS + # Ignore these directories + exclude = (".git",) + if not protect: # Lazy and simple: chmod everything perms = get_add_write_rule(os.stat(deploy_dir, follow_symlinks=False).st_mode) @@ -425,7 +428,7 @@ def set_permissions(deploy_dir: str, protect: bool, dry_run: bool) -> int: logger.info(f"Dry-run: skipping chmod({deploy_dir}, {oct(perms)})") else: os.chmod(deploy_dir, perms) - for dirpath, dirnames, filenames in os.walk(deploy_dir): + for dirpath, dirnames, filenames in exclude_walk(deploy_dir, exclude=exclude): for name in dirnames + filenames: full_path = os.path.join(dirpath, name) perms = get_add_write_rule(os.stat(full_path, follow_symlinks=False).st_mode) @@ -452,6 +455,8 @@ def accumulate_subpaths(subpath: Path): for path in subpath.iterdir(): if path.is_symlink(): continue + elif path.name in exclude: + continue elif path.is_dir(): accumulate_subpaths(path) elif path.is_file(): @@ -467,7 +472,7 @@ def accumulate_subpaths(subpath: Path): logger.info(f"Dry-run: skipping chmod({deploy_dir}, {oct(perms)})") else: os.chmod(deploy_dir, perms) - for dirpath, dirnames, filenames in os.walk(deploy_dir): + for dirpath, dirnames, filenames in exclude_walk(deploy_dir, exclude=exclude): for dirn in dirnames: full_path = os.path.join(dirpath, dirn) if full_path in build_dir_paths: @@ -491,6 +496,23 @@ def accumulate_subpaths(subpath: Path): return ReturnCode.SUCCESS +def exclude_walk(top_dir: str, exclude: Iterator[str]) -> Iterator[Tuple[str, List[str], List[str]]]: + """ + Walk through a directory tree with os.walk but exclude some subdirectories. + """ + for dirpath, dirnames, filenames in os.walk(top_dir): + for ecl in exclude: + try: + dirnames.remove(ecl) + except ValueError: + ... + try: + filenames.remove(ecl) + except ValueError: + ... + yield dirpath, dirnames, filenames + + def get_remove_write_rule(perms: int) -> int: """ Given some existing file permissions, return the same permissions with no writes permitted. From 4a6bca38742f76a698c3859876acd4e497d9e7a3 Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Fri, 30 Aug 2024 17:55:35 -0700 Subject: [PATCH 14/35] REF: simplify user-facing write protection options and internal name consistency --- scripts/ioc_deploy.py | 100 ++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 53 deletions(-) diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index 3d2d153c..88fd789c 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -51,8 +51,7 @@ class CliArgs: release: str = "" ioc_dir: str = "" github_org: str = "" - remove_write_protection: bool = False - apply_write_protection: bool = False + allow_write: bool | None = None path_override: str = "" auto_confirm: bool = False dry_run: bool = False @@ -100,15 +99,12 @@ def get_parser() -> argparse.ArgumentParser: 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( - "--remove-write-protection", - action="store_true", - help="If provided, instead of doing a release, we will remove write protection from an existing release. Incompatible with --apply-write-protection." - ) - parser.add_argument( - "--apply-write-protection", - action="store_true", - help="If provided, instead of doing a release, we will add write protection to an existing release. Incompatible with --remove-write-protection." + "--allow-write", + action="store", + type=parse_allow_write, + help="If provided, instead of doing a release, we will chmod an existing release to allow or prevent writes. Choose from 'true', 'yes', 'false', 'no', or any shortening of these." ) + parser.add_argument( "--path-override", action="store", @@ -137,6 +133,14 @@ def get_parser() -> argparse.ArgumentParser: return parser +def parse_allow_write(option: str): + if option[0].lower() in ("t", "y"): + return True + elif option[0].lower() in ("f", "n"): + return False + raise ValueError(f"{option} is not a valid argument") + + class ReturnCode(enum.IntEnum): SUCCESS = 0 EXCEPTION = 1 @@ -188,7 +192,7 @@ def main_deploy(args: CliArgs) -> int: 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, protect=True, dry_run=args.dry_run) + 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 @@ -205,34 +209,24 @@ def main_perms(args: CliArgs) -> int: Will either return an int code or raise. """ logger.info("Running ioc-deploy write protection change: checking inputs") - if args.apply_write_protection and args.remove_write_protection: - logger.error("Must provide at most one of --apply-write-protection and --remove-write-protection") + if args.allow_write is None: + logger.error("Entered main_perms without args.apply_write selected") return ReturnCode.EXCEPTION deploy_dir, _, _ = pick_deploy_dir(args) - rval = None - if args.apply_write_protection: - logger.info(f"Applying write protection to {deploy_dir}") - if not args.auto_confirm: - user_text = input("Confirm target? y/n\n") - if not user_text.strip().lower().startswith("y"): - return ReturnCode.NO_CONFIRM - rval = set_permissions(deploy_dir=deploy_dir, protect=True, dry_run=args.dry_run) - elif args.remove_write_protection: - logger.info(f"Removing write protection from {deploy_dir}") - if not args.auto_confirm: - user_text = input("Confirm target? y/n\n") - if not user_text.strip().lower().startswith("y"): - return ReturnCode.NO_CONFIRM - rval = set_permissions(deploy_dir=deploy_dir, protect=False, dry_run=args.dry_run) + if args.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? y/n\n") + if not user_text.strip().lower().startswith("y"): + return ReturnCode.NO_CONFIRM + rval = set_permissions(deploy_dir=deploy_dir, allow_write=args.allow_write, dry_run=args.dry_run) if rval == ReturnCode.SUCCESS: logger.info("Write protection change complete!") - elif rval is None: - logger.error("Invalid codepath, how did you get here? Submit a bug report please.") - return ReturnCode.EXCEPTION - return rval @@ -398,19 +392,19 @@ def make_in(deploy_dir: str, dry_run: bool) -> int: return subprocess.run(["make"], cwd=deploy_dir).returncode -def set_permissions(deploy_dir: str, protect: bool, dry_run: bool) -> int: +def set_permissions(deploy_dir: str, allow_write: bool, dry_run: bool) -> int: """ Apply or remove write protection from a deploy repo. You may or may not have permissions to do this to releases created by other users. - Applying write protection (protect=True) involves removing the "w" permissions - from all files, and from directories that contain untracked files. - We will also remove permissions from the top-level direcotry. - - Removing write protection (protect=False) involves adding "w" permissions + allow_write=True involves adding "w" permissions 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 from directories that contain untracked files. + We will also remove 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 @@ -421,9 +415,9 @@ def set_permissions(deploy_dir: str, protect: bool, dry_run: bool) -> int: # Ignore these directories exclude = (".git",) - if not protect: + if allow_write: # Lazy and simple: chmod everything - perms = get_add_write_rule(os.stat(deploy_dir, follow_symlinks=False).st_mode) + perms = get_allow_write_rule(deploy_dir) if dry_run: logger.info(f"Dry-run: skipping chmod({deploy_dir}, {oct(perms)})") else: @@ -431,7 +425,7 @@ def set_permissions(deploy_dir: str, protect: bool, dry_run: bool) -> int: for dirpath, dirnames, filenames in exclude_walk(deploy_dir, exclude=exclude): for name in dirnames + filenames: full_path = os.path.join(dirpath, name) - perms = get_add_write_rule(os.stat(full_path, follow_symlinks=False).st_mode) + perms = get_allow_write_rule(full_path) if dry_run: logger.debug(f"Dry-run: skipping chmod({full_path}, {oct(perms)})") else: @@ -467,7 +461,7 @@ def accumulate_subpaths(subpath: Path): logger.debug(f"Discovered build dir paths {build_dir_paths}") # Follow the write protection rules from the docstring - perms = get_remove_write_rule(os.stat(deploy_dir, follow_symlinks=False).st_mode) + perms = get_remove_write_rule(deploy_dir) if dry_run: logger.info(f"Dry-run: skipping chmod({deploy_dir}, {oct(perms)})") else: @@ -476,7 +470,7 @@ def accumulate_subpaths(subpath: Path): for dirn in dirnames: full_path = os.path.join(dirpath, dirn) if full_path in build_dir_paths: - perms = get_remove_write_rule(os.stat(full_path, follow_symlinks=False).st_mode) + perms = get_remove_write_rule(full_path) if dry_run: logger.debug(f"Dry-run: skipping chmod({full_path}, {oct(perms)})") else: @@ -486,7 +480,7 @@ def accumulate_subpaths(subpath: Path): logger.debug(f"Skip directory perms on {full_path}, not in build dir paths") for filn in filenames: full_path = os.path.join(dirpath, filn) - perms = get_remove_write_rule(os.stat(full_path, follow_symlinks=False).st_mode) + perms = get_remove_write_rule(full_path) if dry_run: logger.debug(f"Dry-run: skipping chmod({full_path}, {oct(perms)})") else: @@ -513,18 +507,18 @@ def exclude_walk(top_dir: str, exclude: Iterator[str]) -> Iterator[Tuple[str, Li yield dirpath, dirnames, filenames -def get_remove_write_rule(perms: int) -> int: +def get_remove_write_rule(path: str) -> int: """ - Given some existing file permissions, return the same permissions with no writes permitted. + Given some file with existing permissions, return the same permissions but with no writes permitted. """ - return perms & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH) + return os.stat(path, follow_symlinks=False).st_mode & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH) -def get_add_write_rule(perms: int) -> int: +def get_allow_write_rule(path: str) -> int: """ - Given some existing file permissions, return the same permissions with user and group writes permitted. + Given some file with existing permissions, return the same permissions but with user and group writes permitted. """ - return perms | stat.S_IWUSR | stat.S_IWGRP + return os.stat(path, follow_symlinks=False).st_mode | stat.S_IWUSR | stat.S_IWGRP def get_version() -> str: @@ -591,10 +585,10 @@ def _main() -> int: if args.version: print(get_version()) return ReturnCode.SUCCESS - if args.apply_write_protection or args.remove_write_protection: - return main_perms(args) - else: + if args.allow_write is None: return main_deploy(args) + else: + return main_perms(args) except Exception as exc: logger.error(exc) logger.debug("Traceback", exc_info=True) From c9e17be40b5f05c7817467b4ff89489c94bd8f00 Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Fri, 30 Aug 2024 17:58:29 -0700 Subject: [PATCH 15/35] ENH: add shortening for path override option --- scripts/ioc_deploy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index 88fd789c..d03182ba 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -107,6 +107,7 @@ def get_parser() -> argparse.ArgumentParser: 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.", ) From c71d6b77627e1b803f56dea8fa9bdbf76d8194ea Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Fri, 30 Aug 2024 18:12:15 -0700 Subject: [PATCH 16/35] DOC: nitpick spacing and clarity in help text --- scripts/ioc_deploy.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index d03182ba..9d6bc906 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -1,23 +1,25 @@ #!/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. -If the tag directory already exists, the script will exit. -After making the IOC, we'll write-protect all files and all directories that contain built files -(e.g. those that contain files that are not tracked in git). + +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. + +After making the IOC, we'll write-protect all files and all directories that +contain built file (e.g. those that contain files that are not tracked in git). We'll also write-protect the top-level directory to help indicate completion. 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. +"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. """ import argparse @@ -66,7 +68,11 @@ def get_parser() -> argparse.ArgumentParser: 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 = argparse.ArgumentParser( + prog="ioc-deploy", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) parser.add_argument( "--version", action="store_true", help="Show version number and exit." ) From 8be731cd4e78afab97af0698588747f06ad499f0 Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Fri, 30 Aug 2024 18:13:09 -0700 Subject: [PATCH 17/35] MAINT: this is the only dry-run print with a period, remove the period --- scripts/ioc_deploy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py index 9d6bc906..afd78783 100644 --- a/scripts/ioc_deploy.py +++ b/scripts/ioc_deploy.py @@ -370,7 +370,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) From 399386ba3e7770590c9f53292d0440e25f2bff5e Mon Sep 17 00:00:00 2001 From: Zachary Lentz Date: Fri, 30 Aug 2024 18:17:32 -0700 Subject: [PATCH 18/35] DOC: paste new help text into readme and maintain preformatted spacing --- README.md | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6b7e7c0a..d11ac9b6 100644 --- a/README.md +++ b/README.md @@ -343,16 +343,30 @@ 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]
+                  [--allow-write ALLOW_WRITE] [--path-override PATH_OVERRIDE]
                   [--auto-confirm] [--dry-run] [--verbose]
  
-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
+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.
+will exit.
+ 
+After making the IOC, we'll write-protect all files and all directories that
+contain built file (e.g. those that contain files that are not tracked in git).
+We'll also write-protect the top-level directory to help indicate completion.
+ 
+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.
  
 optional arguments:
   -h, --help            show this help message and exit
@@ -374,6 +388,16 @@ optional arguments:
                         $GITHUB_ORG, or pcdshub if the environment variable is
                         not set. With your current environment variables, this
                         defaults to pcdshub.
+  --allow-write ALLOW_WRITE
+                        If provided, instead of doing a release, we will chmod
+                        an existing release to allow or prevent writes. Choose
+                        from 'true', 'yes', 'false', 'no', or any shortening
+                        of these.
+  --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.

From b37037be816a5513e97fad37ee1c5529c2642cfc Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Fri, 30 Aug 2024 18:19:04 -0700
Subject: [PATCH 19/35] NIT: why is there a newline here

---
 scripts/ioc_deploy.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index afd78783..a808f054 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -110,7 +110,6 @@ def get_parser() -> argparse.ArgumentParser:
         type=parse_allow_write,
         help="If provided, instead of doing a release, we will chmod an existing release to allow or prevent writes. Choose from 'true', 'yes', 'false', 'no', or any shortening of these."
     )
-
     parser.add_argument(
         "--path-override",
         "-p",

From ec990b54cd373fbcc456bbc8d52b3ee579937b49 Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Tue, 3 Sep 2024 10:37:12 -0700
Subject: [PATCH 20/35] REF: use dataclasses instead of tuple passing for
 clarity

---
 scripts/ioc_deploy.py | 61 +++++++++++++++++++++++++++----------------
 1 file changed, 39 insertions(+), 22 deletions(-)

diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index a808f054..f0df3107 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -32,7 +32,7 @@
 import sys
 from pathlib import Path
 from tempfile import TemporaryDirectory
-from typing import Iterator, List, Optional, Tuple
+from typing import Iterator, List, Tuple
 
 EPICS_SITE_TOP_DEFAULT = "/cds/group/pcds/epics"
 GITHUB_ORG_DEFAULT = "pcdshub"
@@ -59,8 +59,22 @@ class CliArgs:
         dry_run: bool = False
         verbose: bool = False
         version: bool = False
+
+    @dataclasses.dataclass(frozen=True)
+    class DeployInfo:
+        """
+        Finalized deploy name and release information.
+        """
+
+        deploy_dir: str
+        pkg_name: str | None
+        rel_name: str | None
+
 else:
-    from types import SimpleNamespace as CliArgs
+    from types import SimpleNamespace
+
+    CliArgs = SimpleNamespace
+    DeployInfo = SimpleNamespace
 
 
 def get_parser() -> argparse.ArgumentParser:
@@ -166,13 +180,16 @@ def main_deploy(args: CliArgs) -> int:
         logger.error("Must provide both --name and --release. Check ioc-deploy --help for usage.")
         return ReturnCode.EXCEPTION
 
-    deploy_dir, upd_name, upd_rel = pick_deploy_dir(args)
+    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 upd_name is None or upd_rel is None:
-        logger.error(f"Something went wrong at package/tag normalization: package {upd_name} at version {upd_rel}")
+    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(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:
@@ -181,9 +198,9 @@ def main_deploy(args: CliArgs) -> int:
             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,
@@ -219,7 +236,7 @@ def main_perms(args: CliArgs) -> int:
         logger.error("Entered main_perms without args.apply_write selected")
         return ReturnCode.EXCEPTION
 
-    deploy_dir, _, _ = pick_deploy_dir(args)
+    deploy_dir = get_deploy_info(args).deploy_dir
 
     if args.allow_write:
         logger.info(f"Allowing writes to {deploy_dir}")
@@ -236,36 +253,36 @@ def main_perms(args: CliArgs) -> int:
     return rval
 
 
-def pick_deploy_dir(args: CliArgs) -> Tuple[str, Optional[str], Optional[str]]:
+def get_deploy_info(args: CliArgs) -> DeployInfo:
     """
     Normalize user inputs and figure out where to deploy to.
 
-    Returns a tuple of three elements:
-    - The deploy dir
-    - A normalized package name if applicable, or None
-    - A normalized tag name if applicable, or None
+    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:
-        upd_name = finalize_name(
+        pkg_name = finalize_name(
             name=args.name, github_org=args.github_org, verbose=args.verbose
         )
     else:
-        upd_name = None
-    if upd_name and args.github_org and args.release:
-        upd_rel = finalize_tag(
-            name=upd_name,
+        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:
-        upd_rel = None
+        rel_name = None
     if args.path_override:
         deploy_dir = args.path_override
     else:
-        deploy_dir = get_target_dir(name=upd_name, ioc_dir=args.ioc_dir, release=upd_rel)
+        deploy_dir = get_target_dir(name=pkg_name, ioc_dir=args.ioc_dir, release=rel_name)
 
-    return deploy_dir, upd_name, upd_rel
+    return DeployInfo(deploy_dir=deploy_dir, pkg_name=pkg_name, rel_name=rel_name)
 
 
 def finalize_name(name: str, github_org: str, verbose: bool) -> str:

From 3bc83135f084cad501e151761e967f2a33db6adf Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Tue, 3 Sep 2024 10:38:23 -0700
Subject: [PATCH 21/35] STY: apply ruff formatting

---
 scripts/ioc_deploy.py | 39 +++++++++++++++++++++++++++++----------
 1 file changed, 29 insertions(+), 10 deletions(-)

diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index f0df3107..582f6d23 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -22,6 +22,7 @@
 to /cds/group/pcds/epics/ioc/foo/bar/R1.0.0
 then cd and make and chmod as appropriate.
 """
+
 import argparse
 import enum
 import logging
@@ -122,7 +123,7 @@ def get_parser() -> argparse.ArgumentParser:
         "--allow-write",
         action="store",
         type=parse_allow_write,
-        help="If provided, instead of doing a release, we will chmod an existing release to allow or prevent writes. Choose from 'true', 'yes', 'false', 'no', or any shortening of these."
+        help="If provided, instead of doing a release, we will chmod an existing release to allow or prevent writes. Choose from 'true', 'yes', 'false', 'no', or any shortening of these.",
     )
     parser.add_argument(
         "--path-override",
@@ -177,7 +178,9 @@ def main_deploy(args: CliArgs) -> int:
     """
     logger.info("Running ioc-deploy: checking inputs")
     if not (args.name and args.release):
-        logger.error("Must provide both --name and --release. Check ioc-deploy --help for usage.")
+        logger.error(
+            "Must provide both --name and --release. Check ioc-deploy --help for usage."
+        )
         return ReturnCode.EXCEPTION
 
     deploy_info = get_deploy_info(args)
@@ -186,7 +189,9 @@ def main_deploy(args: CliArgs) -> int:
     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}")
+        logger.error(
+            f"Something went wrong at package/tag normalization: package {pkg_name} at version {rel_name}"
+        )
         return ReturnCode.EXCEPTION
 
     logger.info(f"Deploying {args.github_org}/{pkg_name} at {rel_name} to {deploy_dir}")
@@ -215,7 +220,9 @@ def main_deploy(args: CliArgs) -> int:
         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)
+    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
@@ -246,7 +253,9 @@ def main_perms(args: CliArgs) -> int:
         user_text = input("Confirm target? y/n\n")
         if not user_text.strip().lower().startswith("y"):
             return ReturnCode.NO_CONFIRM
-    rval = set_permissions(deploy_dir=deploy_dir, allow_write=args.allow_write, dry_run=args.dry_run)
+    rval = set_permissions(
+        deploy_dir=deploy_dir, allow_write=args.allow_write, dry_run=args.dry_run
+    )
 
     if rval == ReturnCode.SUCCESS:
         logger.info("Write protection change complete!")
@@ -280,7 +289,9 @@ def get_deploy_info(args: CliArgs) -> DeployInfo:
     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)
+        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)
 
@@ -465,7 +476,9 @@ def set_permissions(deploy_dir: str, allow_write: bool, dry_run: bool) -> int:
     except subprocess.CalledProcessError as exc:
         return exc.returncode
     deploy_path = Path(deploy_dir)
-    tracked_paths = [deploy_path / subpath.strip() for subpath in ls_files_output.splitlines()]
+    tracked_paths = [
+        deploy_path / subpath.strip() for subpath in ls_files_output.splitlines()
+    ]
     build_dir_paths = set()
 
     def accumulate_subpaths(subpath: Path):
@@ -500,7 +513,9 @@ def accumulate_subpaths(subpath: Path):
                     logger.debug(f"chmod({full_path}, {oct(perms)})")
                     os.chmod(full_path, perms)
             else:
-                logger.debug(f"Skip directory perms on {full_path}, not in build dir paths")
+                logger.debug(
+                    f"Skip directory perms on {full_path}, not in build dir paths"
+                )
         for filn in filenames:
             full_path = os.path.join(dirpath, filn)
             perms = get_remove_write_rule(full_path)
@@ -513,7 +528,9 @@ def accumulate_subpaths(subpath: Path):
     return ReturnCode.SUCCESS
 
 
-def exclude_walk(top_dir: str, exclude: Iterator[str]) -> Iterator[Tuple[str, List[str], List[str]]]:
+def exclude_walk(
+    top_dir: str, exclude: Iterator[str]
+) -> Iterator[Tuple[str, List[str], List[str]]]:
     """
     Walk through a directory tree with os.walk but exclude some subdirectories.
     """
@@ -534,7 +551,9 @@ def get_remove_write_rule(path: str) -> int:
     """
     Given some file with existing permissions, return the same permissions but with no writes permitted.
     """
-    return os.stat(path, follow_symlinks=False).st_mode & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH)
+    return os.stat(path, follow_symlinks=False).st_mode & ~(
+        stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH
+    )
 
 
 def get_allow_write_rule(path: str) -> int:

From fe9bad4be60040c46b1cda3f22fcad87f4b946d9 Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Wed, 4 Sep 2024 13:30:04 -0700
Subject: [PATCH 22/35] STY: ruff format

---
 scripts/ioc_deploy.py | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index 03c21cf5..121f44c4 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -273,7 +273,10 @@ def get_deploy_info(args: CliArgs) -> DeployInfo:
     """
     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
+            name=args.name,
+            github_org=args.github_org,
+            ioc_dir=args.ioc_dir,
+            verbose=args.verbose,
         )
     else:
         pkg_name = None
@@ -377,7 +380,9 @@ def finalize_name(name: str, github_org: str, ioc_dir: str, verbose: bool) -> st
     if not found_suffix:
         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]
+        suffix = split_ioc_name(casing_from_readme(name=name, readme_text=readme_text))[
+            2
+        ]
 
     name = "-".join(("ioc", area, suffix))
     logger.info(f"Using casing: {name}")

From 9c238d0e5a32de3f6b0a8b8b15d18162e329a49f Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Wed, 4 Sep 2024 14:46:46 -0700
Subject: [PATCH 23/35] ENH: enshrine a typo I keep making

---
 scripts/ioc_deploy.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index 121f44c4..644bd49e 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -121,6 +121,7 @@ def get_parser() -> argparse.ArgumentParser:
     )
     parser.add_argument(
         "--allow-write",
+        "--allow-writes",
         action="store",
         type=parse_allow_write,
         help="If provided, instead of doing a release, we will chmod an existing release to allow or prevent writes. Choose from 'true', 'yes', 'false', 'no', or any shortening of these.",

From e371f47c6b6c885929f41127896b1778a0493fc8 Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Wed, 4 Sep 2024 15:04:24 -0700
Subject: [PATCH 24/35] MNT: simplify permission handling

---
 scripts/ioc_deploy.py | 147 +++++++++---------------------------------
 1 file changed, 31 insertions(+), 116 deletions(-)

diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index 644bd49e..a5ad549a 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -6,8 +6,7 @@
 correct path and "make" it. If the tag directory already exists, the script
 will exit.
 
-After making the IOC, we'll write-protect all files and all directories that
-contain built file (e.g. those that contain files that are not tracked in git).
+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.
 
 Example command:
@@ -33,7 +32,7 @@
 import sys
 from pathlib import Path
 from tempfile import TemporaryDirectory
-from typing import Iterator, List, Tuple
+from typing import Tuple
 
 EPICS_SITE_TOP_DEFAULT = "/cds/group/pcds/epics"
 GITHUB_ORG_DEFAULT = "pcdshub"
@@ -531,17 +530,15 @@ def make_in(deploy_dir: str, dry_run: bool) -> int:
 
 def set_permissions(deploy_dir: str, allow_write: bool, dry_run: bool) -> int:
     """
-    Apply or remove write protection from a deploy repo.
+    Apply or remove write permissions from a deploy repo.
 
-    You may or may not have permissions to do this to releases created by other users.
-
-    allow_write=True involves adding "w" permissions
+    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 from directories that contain untracked files.
-    We will also remove permissions from the top-level direcotry.
+    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
@@ -549,121 +546,39 @@ def set_permissions(deploy_dir: str, allow_write: bool, dry_run: bool) -> int:
         logger.info("Dry-run: skipping permission changes on never-made directory")
         return ReturnCode.SUCCESS
 
-    # Ignore these directories
-    exclude = (".git",)
+    set_one_permission(deploy_dir, allow_write=allow_write, dry_run=dry_run)
 
-    if allow_write:
-        # Lazy and simple: chmod everything
-        perms = get_allow_write_rule(deploy_dir)
-        if dry_run:
-            logger.info(f"Dry-run: skipping chmod({deploy_dir}, {oct(perms)})")
-        else:
-            os.chmod(deploy_dir, perms)
-        for dirpath, dirnames, filenames in exclude_walk(deploy_dir, exclude=exclude):
-            for name in dirnames + filenames:
-                full_path = os.path.join(dirpath, name)
-                perms = get_allow_write_rule(full_path)
-                if dry_run:
-                    logger.debug(f"Dry-run: skipping chmod({full_path}, {oct(perms)})")
-                else:
-                    logger.debug(f"chmod({full_path}, {oct(perms)})")
-                    os.chmod(full_path, perms)
-        return ReturnCode.SUCCESS
-
-    # Compare the files that exist to the files that are tracked by git
-    try:
-        ls_files_output = subprocess.check_output(
-            ["git", "-C", deploy_dir, "ls-files"],
-            universal_newlines=True,
-        )
-    except subprocess.CalledProcessError as exc:
-        return exc.returncode
-    deploy_path = Path(deploy_dir)
-    tracked_paths = [
-        deploy_path / subpath.strip() for subpath in ls_files_output.splitlines()
-    ]
-    build_dir_paths = set()
-
-    def accumulate_subpaths(subpath: Path):
-        for path in subpath.iterdir():
-            if path.is_symlink():
-                continue
-            elif path.name in exclude:
-                continue
-            elif path.is_dir():
-                accumulate_subpaths(path)
-            elif path.is_file():
-                if path not in tracked_paths:
-                    build_dir_paths.add(str(path.parent))
-
-    accumulate_subpaths(deploy_path)
-    logger.debug(f"Discovered build dir paths {build_dir_paths}")
-
-    # Follow the write protection rules from the docstring
-    perms = get_remove_write_rule(deploy_dir)
-    if dry_run:
-        logger.info(f"Dry-run: skipping chmod({deploy_dir}, {oct(perms)})")
-    else:
-        os.chmod(deploy_dir, perms)
-    for dirpath, dirnames, filenames in exclude_walk(deploy_dir, exclude=exclude):
-        for dirn in dirnames:
-            full_path = os.path.join(dirpath, dirn)
-            if full_path in build_dir_paths:
-                perms = get_remove_write_rule(full_path)
-                if dry_run:
-                    logger.debug(f"Dry-run: skipping chmod({full_path}, {oct(perms)})")
-                else:
-                    logger.debug(f"chmod({full_path}, {oct(perms)})")
-                    os.chmod(full_path, perms)
-            else:
-                logger.debug(
-                    f"Skip directory perms on {full_path}, not in build dir paths"
-                )
-        for filn in filenames:
-            full_path = os.path.join(dirpath, filn)
-            perms = get_remove_write_rule(full_path)
-            if dry_run:
-                logger.debug(f"Dry-run: skipping chmod({full_path}, {oct(perms)})")
-            else:
-                logger.debug(f"chmod({full_path}, {oct(perms)})")
-                os.chmod(full_path, perms)
+    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 exclude_walk(
-    top_dir: str, exclude: Iterator[str]
-) -> Iterator[Tuple[str, List[str], List[str]]]:
-    """
-    Walk through a directory tree with os.walk but exclude some subdirectories.
+def set_one_permission(path: str, allow_write: bool, dry_run: bool):
     """
-    for dirpath, dirnames, filenames in os.walk(top_dir):
-        for ecl in exclude:
-            try:
-                dirnames.remove(ecl)
-            except ValueError:
-                ...
-            try:
-                filenames.remove(ecl)
-            except ValueError:
-                ...
-        yield dirpath, dirnames, filenames
-
-
-def get_remove_write_rule(path: str) -> int:
-    """
-    Given some file with existing permissions, return the same permissions but with no writes permitted.
-    """
-    return os.stat(path, follow_symlinks=False).st_mode & ~(
-        stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH
-    )
+    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.
 
-def get_allow_write_rule(path: str) -> int:
+    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.
     """
-    Given some file with existing permissions, return the same permissions but with user and group writes permitted.
-    """
-    return os.stat(path, follow_symlinks=False).st_mode | stat.S_IWUSR | stat.S_IWGRP
+    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)}")
+        os.chmod(path, new_mode, follow_symlinks=False)
 
 
 def get_version() -> str:

From 541356f25a0220fc0df36f1f66307b4b772999e3 Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Wed, 4 Sep 2024 15:04:41 -0700
Subject: [PATCH 25/35] STY: ruff format

---
 scripts/ioc_deploy.py | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index a5ad549a..f6d8845a 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -571,9 +571,7 @@ def set_one_permission(path: str, allow_write: bool, dry_run: bool):
     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
-        )
+        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:

From 64754da8ca210c2b1424d268139af9f22be0f2fd Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Wed, 4 Sep 2024 15:40:10 -0700
Subject: [PATCH 26/35] FIX: handle symbolic links properly

---
 scripts/ioc_deploy.py | 11 +++++++++--
 1 file changed, 9 insertions(+), 2 deletions(-)

diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index f6d8845a..218b9dc8 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -36,6 +36,7 @@
 
 EPICS_SITE_TOP_DEFAULT = "/cds/group/pcds/epics"
 GITHUB_ORG_DEFAULT = "pcdshub"
+CHMOD_SYMLINKS = os.chmod in os.supports_follow_symlinks
 
 logger = logging.getLogger("ioc-deploy")
 
@@ -556,7 +557,7 @@ def set_permissions(deploy_dir: str, allow_write: bool, dry_run: bool) -> int:
     return ReturnCode.SUCCESS
 
 
-def set_one_permission(path: str, allow_write: bool, dry_run: bool):
+def set_one_permission(path: str, allow_write: bool, dry_run: bool) -> None:
     """
     Given some file, adjust the permissions as needed for this script.
 
@@ -567,6 +568,9 @@ def set_one_permission(path: str, allow_write: bool, dry_run: bool):
     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
@@ -576,7 +580,10 @@ def set_one_permission(path: str, allow_write: bool, dry_run: bool):
         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)}")
-        os.chmod(path, new_mode, follow_symlinks=False)
+        if CHMOD_SYMLINKS:
+            os.chmod(path, new_mode, follow_symlinks=False)
+        else:
+            os.chmod(path, new_mode)
 
 
 def get_version() -> str:

From 4d7cd49103009a790adfea592ed6113d1d3ecb1b Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Wed, 4 Sep 2024 15:48:52 -0700
Subject: [PATCH 27/35] DOC: update readme and help text with updated
 information about write protection.

---
 README.md             | 31 +++++++++++++++++++++++++------
 scripts/ioc_deploy.py | 28 ++++++++++++++++++++++++----
 2 files changed, 49 insertions(+), 10 deletions(-)

diff --git a/README.md b/README.md
index d11ac9b6..410266d8 100644
--- a/README.md
+++ b/README.md
@@ -348,14 +348,20 @@ usage: ioc-deploy [-h] [--version] [--name NAME] [--release RELEASE]
  
 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.
+It has two paths: the normal deploy path, and a second path that adjusts
+write permissions on an existing deployed release.
  
-After making the IOC, we'll write-protect all files and all directories that
-contain built file (e.g. those that contain files that are not tracked in git).
+The normal deploy path 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 normal path, 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"
@@ -368,6 +374,19 @@ 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 path 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 one new argument,
+or it can be done by passing the path you'd like to modify
+if this is more convenient for you.
+ 
+Example commands:
+ 
+"ioc-deploy -n ioc-foo-bar -r R1.0.0 --allow-write true"
+"ioc-deploy -n ioc-foo-bar -r R1.0.0 --allow-write false"
+"ioc-deploy -p /cds/group/pcds/epics/ioc/foo/bar/R1.0.0 --allow-write true"
+"ioc-deploy -p /cds/group/pcds/epics/ioc/foo/bar/R1.0.0 --allow-write false"
+ 
 optional arguments:
   -h, --help            show this help message and exit
   --version             Show version number and exit.
@@ -388,7 +407,7 @@ optional arguments:
                         $GITHUB_ORG, or pcdshub if the environment variable is
                         not set. With your current environment variables, this
                         defaults to pcdshub.
-  --allow-write ALLOW_WRITE
+  --allow-write ALLOW_WRITE, --allow-writes ALLOW_WRITE
                         If provided, instead of doing a release, we will chmod
                         an existing release to allow or prevent writes. Choose
                         from 'true', 'yes', 'false', 'no', or any shortening
diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index 218b9dc8..0c61cf99 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -2,13 +2,20 @@
 """
 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.
+It has two paths: the normal deploy path, and a second path that adjusts
+write permissions on an existing deployed release.
 
-After making the IOC, we'll write-protect all files and all directories.
+The normal deploy path 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 normal path, 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"
@@ -20,6 +27,19 @@
 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 path 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 one new argument,
+or it can be done by passing the path you'd like to modify
+if this is more convenient for you.
+
+Example commands:
+
+"ioc-deploy -n ioc-foo-bar -r R1.0.0 --allow-write true"
+"ioc-deploy -n ioc-foo-bar -r R1.0.0 --allow-write false"
+"ioc-deploy -p /cds/group/pcds/epics/ioc/foo/bar/R1.0.0 --allow-write true"
+"ioc-deploy -p /cds/group/pcds/epics/ioc/foo/bar/R1.0.0 --allow-write false"
 """
 
 import argparse

From 339bfc14693ead8aa8db6a13fdc9268f31a64dc3 Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Wed, 4 Sep 2024 16:03:22 -0700
Subject: [PATCH 28/35] Consolidate yes/no handling and return code logging

---
 scripts/ioc_deploy.py | 35 ++++++++++++++++++++++++-----------
 1 file changed, 24 insertions(+), 11 deletions(-)

diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index 0c61cf99..81315a38 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -143,7 +143,7 @@ def get_parser() -> argparse.ArgumentParser:
         "--allow-write",
         "--allow-writes",
         action="store",
-        type=parse_allow_write,
+        type=is_yes,
         help="If provided, instead of doing a release, we will chmod an existing release to allow or prevent writes. Choose from 'true', 'yes', 'false', 'no', or any shortening of these.",
     )
     parser.add_argument(
@@ -175,10 +175,15 @@ def get_parser() -> argparse.ArgumentParser:
     return parser
 
 
-def parse_allow_write(option: str):
-    if option[0].lower() in ("t", "y"):
+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[0].lower() in ("f", "n"):
+    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")
 
@@ -219,8 +224,8 @@ def main_deploy(args: CliArgs) -> int:
     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(
@@ -271,8 +276,8 @@ def main_perms(args: CliArgs) -> int:
     else:
         logger.info(f"Preventing writes to {deploy_dir}")
     if not args.auto_confirm:
-        user_text = input("Confirm target? y/n\n")
-        if not user_text.strip().lower().startswith("y"):
+        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
     rval = set_permissions(
         deploy_dir=deploy_dir, allow_write=args.allow_write, dry_run=args.dry_run
@@ -671,13 +676,21 @@ def _main() -> int:
             print(get_version())
             return ReturnCode.SUCCESS
         if args.allow_write is None:
-            return main_deploy(args)
+            rval = main_deploy(args)
         else:
-            return main_perms(args)
+            rval = main_perms(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__":

From 6451d8a2eb8ec99bee82db7b9144dccdcccf2b20 Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Wed, 4 Sep 2024 16:39:32 -0700
Subject: [PATCH 29/35] ENH: remove git from permissions change, consolidate
 input handling

---
 scripts/ioc_deploy.py | 104 ++++++++++++++++++++++++++++--------------
 1 file changed, 69 insertions(+), 35 deletions(-)

diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index 81315a38..56f10f6a 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -52,7 +52,7 @@
 import sys
 from pathlib import Path
 from tempfile import TemporaryDirectory
-from typing import Tuple
+from typing import List, Tuple
 
 EPICS_SITE_TOP_DEFAULT = "/cds/group/pcds/epics"
 GITHUB_ORG_DEFAULT = "pcdshub"
@@ -202,13 +202,6 @@ def main_deploy(args: CliArgs) -> int:
 
     Will either return an int return code or raise.
     """
-    logger.info("Running ioc-deploy: checking inputs")
-    if not (args.name and args.release):
-        logger.error(
-            "Must provide both --name and --release. Check ioc-deploy --help for usage."
-        )
-        return ReturnCode.EXCEPTION
-
     deploy_info = get_deploy_info(args)
     deploy_dir = deploy_info.deploy_dir
     pkg_name = deploy_info.pkg_name
@@ -264,12 +257,11 @@ def main_perms(args: CliArgs) -> int:
 
     Will either return an int code or raise.
     """
-    logger.info("Running ioc-deploy write protection change: checking inputs")
     if args.allow_write is None:
         logger.error("Entered main_perms without args.apply_write selected")
         return ReturnCode.EXCEPTION
 
-    deploy_dir = get_deploy_info(args).deploy_dir
+    deploy_dir = get_perms_target(args)
 
     if args.allow_write:
         logger.info(f"Allowing writes to {deploy_dir}")
@@ -325,6 +317,42 @@ def get_deploy_info(args: CliArgs) -> DeployInfo:
     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.
@@ -383,32 +411,24 @@ 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
         ]
+    logger.info(f"Using {suffix} as the name")
 
     name = "-".join(("ioc", area, suffix))
     logger.info(f"Using casing: {name}")
@@ -470,15 +490,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:
@@ -500,6 +512,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.
@@ -675,6 +703,12 @@ def _main() -> int:
         if args.version:
             print(get_version())
             return ReturnCode.SUCCESS
+        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
         if args.allow_write is None:
             rval = main_deploy(args)
         else:

From 7ebf6575c3370bff1264c2d6a9c0cc11e13e63ea Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Wed, 4 Sep 2024 16:41:56 -0700
Subject: [PATCH 30/35] MNT: nitpick message

---
 scripts/ioc_deploy.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index 56f10f6a..5e30600a 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -245,7 +245,7 @@ def main_deploy(args: CliArgs) -> int:
     if rval != ReturnCode.SUCCESS:
         logger.error(f"Nonzero return value {rval} from set_permissions")
         return rval
-    logger.info("ioc-deploy complete!")
+    logger.info("IOC clone, make, and permission change complete!")
     return ReturnCode.SUCCESS
 
 

From b254a6d333ef6e18d0b19e35ecc58f8729a1be07 Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Thu, 5 Sep 2024 15:06:25 -0700
Subject: [PATCH 31/35] ENH: advise follow-up actions if there is an error
 changing file permissions.

---
 scripts/ioc_deploy.py | 21 ++++++++++++++++++---
 1 file changed, 18 insertions(+), 3 deletions(-)

diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index 5e30600a..994e6747 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -271,9 +271,24 @@ def main_perms(args: CliArgs) -> int:
         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
-    rval = set_permissions(
-        deploy_dir=deploy_dir, allow_write=args.allow_write, dry_run=args.dry_run
-    )
+    try:
+        rval = set_permissions(
+            deploy_dir=deploy_dir, allow_write=args.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 args.allow_write:
+            mode = "ug+w"
+        else:
+            mode = "a-w"
+        logger.error(
+            f"For example, you might try 'sudo chmod -R {mode} {deploy_dir}' from a server you have sudo access on."
+        )
+        return ReturnCode.EXCEPTION
 
     if rval == ReturnCode.SUCCESS:
         logger.info("Write protection change complete!")

From 0d16e598d607c53753237fc0883fba8e62088f1e Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Thu, 5 Sep 2024 16:44:01 -0700
Subject: [PATCH 32/35] STY: win the battle against ruff

---
 scripts/ioc_deploy.py | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index 994e6747..818dab72 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -440,9 +440,8 @@ def finalize_name(name: str, github_org: str, ioc_dir: str, verbose: bool) -> st
     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))

From 66b91e213a36fadeeb47dd2f406f549c8d332c20 Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Thu, 5 Sep 2024 17:46:42 -0700
Subject: [PATCH 33/35] ENH: rework permissions update to be a separate
 subcommand for clarity

---
 scripts/ioc_deploy.py | 195 +++++++++++++++++++++++++++---------------
 1 file changed, 124 insertions(+), 71 deletions(-)

diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index 818dab72..7269db95 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -52,11 +52,12 @@
 import sys
 from pathlib import Path
 from tempfile import TemporaryDirectory
-from typing import List, 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")
 
@@ -74,12 +75,12 @@ class CliArgs:
         release: str = ""
         ioc_dir: str = ""
         github_org: str = ""
-        allow_write: bool | None = None
         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:
@@ -88,8 +89,8 @@ class DeployInfo:
         """
 
         deploy_dir: str
-        pkg_name: str | None
-        rel_name: str | None
+        pkg_name: Optional[str]
+        rel_name: Optional[str]
 
 else:
     from types import SimpleNamespace
@@ -103,76 +104,86 @@ def get_parser() -> argparse.ArgumentParser:
         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(
+    main_parser = argparse.ArgumentParser(
         prog="ioc-deploy",
         description=__doc__,
         formatter_class=argparse.RawDescriptionHelpFormatter,
     )
-    parser.add_argument(
+    # 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-'.",
-    )
-    parser.add_argument(
-        "--release",
-        "-r",
-        action="store",
-        default="",
-        help="The version of the IOC to deploy. This is a required argument.",
+    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(
-        "--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}.",
+    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(
+    # 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(
-        "--allow-write",
-        "--allow-writes",
-        action="store",
-        type=is_yes,
-        help="If provided, instead of doing a release, we will chmod an existing release to allow or prevent writes. Choose from 'true', 'yes', 'false', 'no', or any shortening of these.",
-    )
-    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.",
-    )
-    return parser
+    return main_parser
 
 
 def is_yes(option: str, error_on_empty: bool = True) -> bool:
@@ -188,6 +199,10 @@ def is_yes(option: str, error_on_empty: bool = True) -> bool:
     raise ValueError(f"{option} is not a valid argument")
 
 
+def force_lower(text: str) -> str:
+    return str(text).lower()
+
+
 class ReturnCode(enum.IntEnum):
     SUCCESS = 0
     EXCEPTION = 1
@@ -257,13 +272,16 @@ def main_perms(args: CliArgs) -> int:
 
     Will either return an int code or raise.
     """
-    if args.allow_write is None:
-        logger.error("Entered main_perms without args.apply_write selected")
+    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 args.allow_write:
+    if allow_write:
         logger.info(f"Allowing writes to {deploy_dir}")
     else:
         logger.info(f"Preventing writes to {deploy_dir}")
@@ -273,7 +291,7 @@ def main_perms(args: CliArgs) -> int:
             return ReturnCode.NO_CONFIRM
     try:
         rval = set_permissions(
-            deploy_dir=deploy_dir, allow_write=args.allow_write, dry_run=args.dry_run
+            deploy_dir=deploy_dir, allow_write=allow_write, dry_run=args.dry_run
         )
     except OSError as exc:
         logger.error(f"OSError during chmod: {exc}")
@@ -281,12 +299,12 @@ def main_perms(args: CliArgs) -> int:
         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 args.allow_write:
-            mode = "ug+w"
+        if allow_write:
+            suggest = "ug+w"
         else:
-            mode = "a-w"
+            suggest = "a-w"
         logger.error(
-            f"For example, you might try 'sudo chmod -R {mode} {deploy_dir}' from a server you have sudo access on."
+            f"For example, you might try 'sudo chmod -R {suggest} {deploy_dir}' from a server you have sudo access on."
         )
         return ReturnCode.EXCEPTION
 
@@ -700,10 +718,40 @@ def _clone(
     return subprocess.run(cmd, **kwds)
 
 
+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
@@ -723,10 +771,15 @@ def _main() -> int:
                 "Must provide both --name and --release, or --path-override. Check ioc-deploy --help for usage."
             )
             return ReturnCode.EXCEPTION
-        if args.allow_write is None:
-            rval = main_deploy(args)
-        else:
+        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)

From 7658dbc61e3471cdd2e238d3d45154909b35acb6 Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Thu, 5 Sep 2024 17:52:19 -0700
Subject: [PATCH 34/35] DOC: update help text based on PR review and subparser
 addition

---
 scripts/ioc_deploy.py | 22 +++++++++++-----------
 1 file changed, 11 insertions(+), 11 deletions(-)

diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index 7269db95..d0391c33 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -2,14 +2,14 @@
 """
 ioc-deploy is a script for building and deploying ioc tags from github.
 
-It has two paths: the normal deploy path, and a second path that adjusts
-write permissions on an existing deployed release.
+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 path will create a shallow clone of your IOC in the
+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 normal path, after making the IOC, we'll write-protect all files
+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.
 
@@ -28,18 +28,18 @@
 to /cds/group/pcds/epics/ioc/foo/bar/R1.0.0
 then cd and make and chmod as appropriate.
 
-The second path will not do any git or make actions, it will only find the
+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 one new argument,
-or it can be done by passing the path you'd like to modify
+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 -n ioc-foo-bar -r R1.0.0 --allow-write true"
-"ioc-deploy -n ioc-foo-bar -r R1.0.0 --allow-write false"
-"ioc-deploy -p /cds/group/pcds/epics/ioc/foo/bar/R1.0.0 --allow-write true"
-"ioc-deploy -p /cds/group/pcds/epics/ioc/foo/bar/R1.0.0 --allow-write false"
+"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

From 42d2429ceea3bad77b0617153f16c5b1cb32de67 Mon Sep 17 00:00:00 2001
From: Zachary Lentz 
Date: Thu, 5 Sep 2024 18:30:30 -0700
Subject: [PATCH 35/35] DOC: add a helper function and use it to update the
 readme

---
 README.md             | 78 +++++++++++++++++++++++++++++++++----------
 scripts/ioc_deploy.py | 21 +++++++++++-
 2 files changed, 80 insertions(+), 19 deletions(-)

diff --git a/README.md b/README.md
index 410266d8..0d0ff4d4 100644
--- a/README.md
+++ b/README.md
@@ -342,20 +342,21 @@ usage: grep_more_ioc [-h] [-d] patt hutch {print,search} 
ioc-deploy
 usage: ioc-deploy [-h] [--version] [--name NAME] [--release RELEASE]
-                  [--ioc-dir IOC_DIR] [--github_org GITHUB_ORG]
-                  [--allow-write ALLOW_WRITE] [--path-override PATH_OVERRIDE]
+                  [--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 has two paths: the normal deploy path, and a second path that adjusts
-write permissions on an existing deployed release.
+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 path will create a shallow clone of your IOC in the
+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 normal path, after making the IOC, we'll write-protect all files
+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.
  
@@ -374,18 +375,24 @@ 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 path will not do any git or make actions, it will only find the
+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 one new argument,
-or it can be done by passing the path you'd like to modify
+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 -n ioc-foo-bar -r R1.0.0 --allow-write true"
-"ioc-deploy -n ioc-foo-bar -r R1.0.0 --allow-write false"
-"ioc-deploy -p /cds/group/pcds/epics/ioc/foo/bar/R1.0.0 --allow-write true"
-"ioc-deploy -p /cds/group/pcds/epics/ioc/foo/bar/R1.0.0 --allow-write false"
+"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
@@ -402,16 +409,51 @@ 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.
-  --allow-write ALLOW_WRITE, --allow-writes ALLOW_WRITE
-                        If provided, instead of doing a release, we will chmod
-                        an existing release to allow or prevent writes. Choose
-                        from 'true', 'yes', 'false', 'no', or any shortening
-                        of these.
+ 
+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
diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
index d0391c33..1cd71949 100644
--- a/scripts/ioc_deploy.py
+++ b/scripts/ioc_deploy.py
@@ -99,7 +99,7 @@ class DeployInfo:
     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"
     )
@@ -183,6 +183,8 @@ def get_parser() -> argparse.ArgumentParser:
         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}.",
     )
+    if subparser:
+        return perms_parser
     return main_parser
 
 
@@ -718,6 +720,23 @@ 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.