diff --git a/README.md b/README.md
index 86f22c59..ee76531f 100644
--- a/README.md
+++ b/README.md
@@ -338,6 +338,52 @@ 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]
+ [--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
+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.
+
+optional arguments:
+ -h, --help show this help message and exit
+ --version Show version number 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.
+ --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.
+ --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.
+ |
+
+
iocmanager |
diff --git a/scripts/ioc-deploy b/scripts/ioc-deploy
new file mode 120000
index 00000000..34cc4a13
--- /dev/null
+++ b/scripts/ioc-deploy
@@ -0,0 +1 @@
+ioc_deploy.py
\ No newline at end of file
diff --git a/scripts/ioc_deploy.py b/scripts/ioc_deploy.py
new file mode 100755
index 00000000..fc7d9c13
--- /dev/null
+++ b/scripts/ioc_deploy.py
@@ -0,0 +1,341 @@
+#!/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.
+
+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.
+"""
+
+import argparse
+import enum
+import logging
+import os
+import subprocess
+import sys
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+EPICS_SITE_TOP_DEFAULT = "/cds/group/pcds/epics"
+GITHUB_ORG_DEFAULT = "pcdshub"
+
+logger = logging.getLogger("ioc-deploy")
+
+
+if sys.version_info >= (3, 7, 0):
+ import dataclasses
+
+ @dataclasses.dataclass(frozen=True)
+ class CliArgs:
+ """
+ Argparse argument types for type checking.
+ """
+
+ name: str = ""
+ release: str = ""
+ ioc_dir: str = ""
+ github_org: str = ""
+ auto_confirm: bool = False
+ dry_run: bool = False
+ verbose: bool = False
+ version: bool = False
+else:
+ from types import SimpleNamespace as CliArgs
+
+
+def get_parser() -> argparse.ArgumentParser:
+ current_default_target = str(
+ Path(os.environ.get("EPICS_SITE_TOP", EPICS_SITE_TOP_DEFAULT)) / "ioc"
+ )
+ current_default_org = os.environ.get("GITHUB_ORG", GITHUB_ORG_DEFAULT)
+ parser = argparse.ArgumentParser(prog="ioc-deploy", description=__doc__)
+ parser.add_argument(
+ "--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.",
+ )
+ 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(
+ "--github_org",
+ "--org",
+ action="store",
+ default=current_default_org,
+ help=f"The github org to deploy IOCs from. This defaults to $GITHUB_ORG, or {GITHUB_ORG_DEFAULT} if the environment variable is not set. With your current environment variables, this defaults to {current_default_org}.",
+ )
+ parser.add_argument(
+ "--auto-confirm",
+ "--confirm",
+ "--yes",
+ "-y",
+ action="store_true",
+ help="Skip the confirmation promps, automatically saying yes to each one.",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Do not deploy anything, just print what would have been done.",
+ )
+ parser.add_argument(
+ "--verbose",
+ "-v",
+ "--debug",
+ action="store_true",
+ help="Display additional debug information.",
+ )
+ return parser
+
+
+class ReturnCode(enum.IntEnum):
+ SUCCESS = 0
+ EXCEPTION = 1
+ NO_CONFIRM = 2
+
+
+def main(args: CliArgs) -> int:
+ """
+ All main steps of the script.
+
+ Will either return an int return code or raise.
+ """
+ if not (args.name and args.release):
+ raise ValueError(
+ "Must provide both --name and --release. Check ioc-deploy --help for usage."
+ )
+
+ logger.info("Running ioc-deploy: checking inputs")
+ upd_name = finalize_name(name=args.name, github_org=args.github_org)
+ upd_rel = finalize_tag(
+ name=upd_name, github_org=args.github_org, release=args.release
+ )
+ deploy_dir = get_target_dir(name=upd_name, ioc_dir=args.ioc_dir, release=upd_rel)
+
+ logger.info(f"Deploying {args.github_org}/{upd_name} at {upd_rel} to {deploy_dir}")
+ 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"):
+ return ReturnCode.NO_CONFIRM
+ logger.info(f"Cloning IOC to {deploy_dir}")
+ rval = clone_repo_tag(
+ name=upd_name,
+ github_org=args.github_org,
+ release=upd_rel,
+ deploy_dir=deploy_dir,
+ dry_run=args.dry_run,
+ )
+ if rval != ReturnCode.SUCCESS:
+ logger.error(f"Nonzero return value {rval} from git clone")
+ return rval
+
+ logger.info(f"Building IOC at {deploy_dir}")
+ rval = make_in(deploy_dir=deploy_dir, dry_run=args.dry_run)
+ if rval != ReturnCode.SUCCESS:
+ logger.error(f"Nonzero return value {rval} from make")
+ return rval
+ return ReturnCode.SUCCESS
+
+
+def finalize_name(name: str, github_org: str) -> str:
+ """
+ Check if name is present in org and is well-formed.
+
+ If the name is present, return it.
+ If the name is not present and the correct name can be guessed, guess.
+ If the name is not present and cannot be guessed, raise.
+
+ A name is well-formed if it starts with "ioc", is hyphen-delimited,
+ and has at least three sections.
+
+ For example, "ioc-common-gigECam" is a well-formed name for the purposes
+ of an IOC deployment. "ads-ioc" and "pcdsdevices" are not.
+
+ However, "ads-ioc" will resolve to "ioc-common-ads-ioc".
+ Only common IOCs will be automatically discovered using this method.
+ """
+ split_name = name.split("-")
+ if len(split_name) < 3 or split_name[0] != "ioc":
+ new_name = f"ioc-common-{name}"
+ logger.warning(f"{name} is not an ioc name, trying {new_name}")
+ name = new_name
+ logger.debug(f"Checking for {name} in org {github_org}")
+ try:
+ resp = urllib.request.urlopen(f"https://github.com/{github_org}/{name}")
+ if resp.code == 200:
+ logger.info(f"{name} exists in {github_org}")
+ return name
+ else:
+ logger.error(f"Unexpected http error code {resp.code}")
+ except urllib.error.HTTPError as exc:
+ logger.error(exc)
+ logger.debug("", exc_info=True)
+ raise ValueError(f"{name} does not exist in {github_org}")
+
+
+def finalize_tag(name: str, github_org: str, release: str) -> str:
+ """
+ Check if release is present in the org.
+
+ We'll try a few prefix options in case the user has a typo.
+ In order of priority with no repeats:
+ - user's input
+ - R1.0.0
+ - 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}"])
+
+ for rel in try_release:
+ logger.debug(f"Checking for release {rel} in {github_org}/{name}")
+ try:
+ resp = urllib.request.urlopen(
+ f"https://github.com/{github_org}/{name}/releases/tag/{rel}"
+ )
+ if resp.code == 200:
+ logger.info(f"Release {rel} exists in {github_org}/{name}")
+ return rel
+ else:
+ logger.warning(f"Unexpected http error code {resp.code}")
+ except urllib.error.HTTPError:
+ logger.warning(f"Did not find release {rel} in {github_org}/{name}")
+ raise ValueError(f"Unable to find {release} in {github_org}/{name}")
+
+
+def get_target_dir(name: str, ioc_dir: str, release: str) -> str:
+ """
+ Return the directory we'll deploy the IOC in.
+
+ This will look something like:
+ /cds/group/pcds/epics/ioc/common/gigECam/R1.0.0
+ """
+ split_name = name.split("-")
+ return str(Path(ioc_dir) / split_name[1] / "-".join(split_name[2:]) / release)
+
+
+def clone_repo_tag(
+ name: str, github_org: str, release: str, deploy_dir: str, dry_run: bool
+) -> int:
+ """
+ Create a shallow clone of the git repository in the correct location.
+ """
+ # 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.")
+ else:
+ logger.debug(f"Ensure {parent_dir} exists")
+ parent_dir.mkdir(parents=True, exist_ok=True)
+ # Shell out to git clone
+ cmd = [
+ "git",
+ "clone",
+ f"https://github.com/{github_org}/{name}",
+ "--depth",
+ "1",
+ "-b",
+ release,
+ deploy_dir,
+ ]
+ if dry_run:
+ logger.debug(f"Dry-run: skip shell command \"{' '.join(cmd)}\"")
+ return ReturnCode.SUCCESS
+ else:
+ return subprocess.run(cmd).returncode
+
+
+def make_in(deploy_dir: str, dry_run: bool) -> int:
+ """
+ Shell out to make in the deploy dir
+ """
+ if dry_run:
+ logger.info(f"Dry-run: skipping make in {deploy_dir}")
+ return ReturnCode.SUCCESS
+ else:
+ return subprocess.run(["make"], cwd=deploy_dir).returncode
+
+
+def get_version() -> str:
+ """
+ Determine what version of engineering_tools is being used
+ """
+ # Possibility 1: git clone (yours)
+ try:
+ return subprocess.check_output(
+ ["git", "-C", str(Path(__file__).resolve().parent), "describe", "--tags"],
+ universal_newlines=True,
+ ).strip()
+ except subprocess.CalledProcessError:
+ ...
+ # Possibility 2: release dir (someone elses)
+ ver = str(Path(__file__).resolve().parent.parent.stem)
+ if ver.startswith("R"):
+ return ver
+ else:
+ # We tried our best
+ return "unknown.dev"
+
+
+def _main() -> int:
+ """
+ Thin wrapper of main() for log setup, args handling, and high-level error handling.
+ """
+ args = CliArgs(**vars(get_parser().parse_args()))
+ if args.verbose:
+ level = logging.DEBUG
+ fmt = "%(levelname)-8s %(name)s:%(lineno)d: %(message)s"
+ else:
+ level = logging.INFO
+ fmt = "%(levelname)-8s %(name)s: %(message)s"
+ logging.basicConfig(level=level, format=fmt)
+ logger.debug(f"args are {args}")
+ try:
+ if args.version:
+ print(get_version())
+ return ReturnCode.SUCCESS
+ return main(args)
+ except Exception as exc:
+ logger.error(exc)
+ logger.debug("Traceback", exc_info=True)
+ return ReturnCode.EXCEPTION
+
+
+if __name__ == "__main__":
+ exit(_main())
|