diff --git a/shpc/client/__init__.py b/shpc/client/__init__.py index bd7bd2871..c7186862d 100644 --- a/shpc/client/__init__.py +++ b/shpc/client/__init__.py @@ -126,6 +126,14 @@ def get_parser(): action="store_true", ) + install.add_argument( + "--upgrade", + "-u", + help="Check if the latest version of a software is available and install it if not installed.", + dest="upgrade", + action="store_true", + ) + # List installed modules listing = subparsers.add_parser( "list", @@ -377,7 +385,7 @@ def get_parser(): action="store_true", ) - for command in update, sync: + for command in update, install, sync: command.add_argument( "--dry-run", "-d", diff --git a/shpc/client/help.py b/shpc/client/help.py index b8364e476..34405d2b3 100644 --- a/shpc/client/help.py +++ b/shpc/client/help.py @@ -27,7 +27,7 @@ # Remove from the list $ shpc -c rm:registry:/tmp/registry""" -install_description = """Install a registry recipe. +install_description = """Install a registry recipe or upgrade an installed software. $ Install latest version $ shpc install python @@ -37,6 +37,17 @@ # Install a specific version from that set $ shpc install python:3.9.5-alpine + + # Upgrade a software to its latest version and give option to uninstall older versions or not. + # Do not include the version in the command + $ shpc install python --upgrade + OR + $ shpc install python -u + + # Perform dry-run on a software to check if the latest is installed or not without upgrading it. + $ shpc install python --upgrade --dry-run + OR + $ shpc install python -u -d """ listing_description = """List installed modules. diff --git a/shpc/client/install.py b/shpc/client/install.py index 89299db6e..bee94a194 100644 --- a/shpc/client/install.py +++ b/shpc/client/install.py @@ -3,6 +3,7 @@ __license__ = "MPL 2.0" import shpc.utils +from shpc.logger import logger def main(args, parser, extra, subparser): @@ -23,17 +24,126 @@ def main(args, parser, extra, subparser): # Update config settings on the fly cli.settings.update_params(args.config_params) - # And do the install - cli.install( - args.install_recipe, - force=args.force, - container_image=args.container_image, - keep_path=args.keep_path, - ) - if cli.settings.default_view and not args.no_view: - cli.view_install( - cli.settings.default_view, + # Get the list of the user's installed software + installed_software = cli.list(return_modules=True) + + # Upgrade a specific installed software + if args.upgrade: + # Check if the user specified a version + if ":" in args.install_recipe: + logger.exit( + "Please do not include the software version when using --upgrade argument." + ) + # Check if the specific software is installed + if args.install_recipe not in installed_software: + logger.exit( + f"You cannot carry out an upgrade on {args.install_recipe} because you do not have it installed.\nInstall it first before attempting an upgrade.", + 0, + ) + + # Does the user just want a dry-run? + if args.dryrun: + version_info = upgrade( + args.install_recipe, cli, args, dryrun=True + ) # This returns the latest version if its available, else returns None + if version_info: + logger.info( + f"You do not have the latest version installed.\nLatest version avaiable is {version_info}" + ) + else: + logger.info( + f"You have the latest version of {args.install_recipe} installed." + ) + + # Upgade the software + else: + upgrade( + args.install_recipe, + cli, + args, + dryrun=False, + ) + + # Install a new software + else: + cli.install( args.install_recipe, force=args.force, container_image=args.container_image, + keep_path=args.keep_path, ) + if cli.settings.default_view and not args.no_view: + cli.view_install( + cli.settings.default_view, + args.install_recipe, + force=args.force, + container_image=args.container_image, + ) + + +def upgrade(name, cli, args, dryrun=False): + """ + Upgrade a software to its latest version. Or preview available upgrades from the user's software list + """ + # Add namespace + name = cli.add_namespace(name) + + # Load the container configuration for the specified recipe + config = cli._load_container(name) + + # Store the installed versions and the latest version tag + installed_versions = cli.list(pattern=name, return_modules=True) + latest_version_tag = get_latest_version(name, config) + + # Compare the latest version with the user's installed version + if any(latest_version_tag in versions for versions in installed_versions.values()): + if not dryrun: + logger.info(f"You have the latest version of {name} installed already") + return None # No upgrade necessary + + else: + if dryrun: + return ( + latest_version_tag # Return the latest version for upgrade information + ) + print(f"Upgrading {name} to its latest version. Version {latest_version_tag}") + + # Get the list of views the software was in + views_with_module = set() + view_dir = cli.new_module(name).module_dir + for view_name, entry in cli.views.items(): + if entry.exists(view_dir): + views_with_module.add(view_name) + + # Ask if the user wants to unintall old versions + if not cli.uninstall(name): + logger.info(f"Old versions of {name} were preserved") + + # Install the latest version + cli.install(name) + + # Install the latest version to views where the outdated version was found + if views_with_module: + msg = f"Do you also want to install the latest version of {name} to the view(s) of the previous version(s)?" + if shpc.utils.confirm_action(msg): + for view_name in views_with_module: + cli.view_install(view_name, name) + logger.info( + f"Installed the latest version of {name} to view: {view_name}" + ) + + return latest_version_tag # Upgrade occured + + +def get_latest_version(name, config): + """ + Given an added namespace of a recipe and a loaded container configuration of that namespace, + Retrieve the latest version tag. + """ + latest_version_info = config.get("latest") + if not latest_version_info: + logger.exit(f"No latest version found for {name}") + + # Extract the latest version tag + latest_version_tag = list(latest_version_info.keys())[0] + return latest_version_tag diff --git a/shpc/main/container/update/docker.py b/shpc/main/container/update/docker.py index 35bd98077..44d8920dc 100644 --- a/shpc/main/container/update/docker.py +++ b/shpc/main/container/update/docker.py @@ -10,7 +10,6 @@ class DockerImage: - """ A thin client for getting metadata about an image. """ diff --git a/shpc/main/modules/base.py b/shpc/main/modules/base.py index fa26ba987..28a1663d7 100644 --- a/shpc/main/modules/base.py +++ b/shpc/main/modules/base.py @@ -100,14 +100,14 @@ def uninstall(self, name, force=False): # Ask before deleting anything! if not force: - msg = name + "?" + msg = "Do you wish to uninstall " + name + "?" if views_with_module: msg += ( "\nThis will uninstall the module from views:\n %s\nAre you sure?" % "\n ".join(views_with_module) ) if not utils.confirm_action(msg, force): - return + return False # If the user does not want to uninstall # Podman needs image deletion self.container.delete(module.name) @@ -149,6 +149,8 @@ def uninstall(self, name, force=False): if os.path.exists(module_dir): self.versionfile.write(module_dir) + return True # Denoting successful uninstallation + def _uninstall(self, path, base_path, name): """ Sub function, so we can pass more than one folder from uninstall