Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reinstallation mode #590

Draft
wants to merge 18 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
38 changes: 38 additions & 0 deletions shpc/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,41 @@ def get_parser():
action="store_true",
)

# Reinstall already installed recipes
reinstall = subparsers.add_parser(
"reinstall",
description="reinstall a recipe.",
formatter_class=argparse.RawTextHelpFormatter,
)
reinstall.add_argument(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This argument is likely redundant with one that exists - find a match and then loop through the list to add it is what I usually do! --all might be something we have too (can't remember off the top of my head).

"reinstall_recipe",
nargs="?",
help="recipe to reinstall\nshpc reinstall python\nshpc reinstall python:3.9.5-alpine",
default=None,
)
reinstall.add_argument(
"--all",
dest="all",
help="reinstall all currently installed modules.",
action="store_true",
)
reinstall.add_argument(
"--ignore-missing",
"-i",
dest="ignore_missing",
help="Ignore and leave intact the versions that don't exist in the registry anymore.",
default=False,
action="store_true",
)
reinstall.add_argument(
"--uninstall-missing",
"-u",
dest="uninstall_missing",
help="Uninstall the versions that don't exist in the registry anymore.",
default=False,
action="store_true",
)

# List installed modules
listing = subparsers.add_parser("list", description="list installed modules.")
listing.add_argument("pattern", help="filter to a pattern", nargs="?")
Expand Down Expand Up @@ -372,6 +407,7 @@ def get_parser():
inspect,
install,
listing,
reinstall,
shell,
test,
uninstall,
Expand Down Expand Up @@ -491,6 +527,8 @@ def help(return_code=0):
from .get import main
elif args.command == "install":
from .install import main
elif args.command == "reinstall":
from .reinstall import main
elif args.command == "inspect":
from .inspect import main
elif args.command == "list":
Expand Down
2 changes: 0 additions & 2 deletions shpc/client/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ def main(args, parser, extra, subparser):
# And do the install
cli.install(
args.install_recipe,
force=args.force,
container_image=args.container_image,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay so I see you removed force but we've added "allow_reinstall" - I think "force" implies that, so let's keep the same functionality but have the variable named force to be consistent with the design of other functions. And I don't think there is any reason we should remove the ability to do install --force from the command line, that's also something someone would intuitively want to do.

keep_path=args.keep_path,
)
Expand All @@ -36,5 +35,4 @@ def main(args, parser, extra, subparser):
cli.settings.default_view,
args.install_recipe,
force=args.force,
container_image=args.container_image,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay, and this will work because we are symlinking the module file, which has the path to the container, wherever it may be!

)
46 changes: 46 additions & 0 deletions shpc/client/reinstall.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
__author__ = "Vanessa Sochat"
__copyright__ = "Copyright 2021-2022, Vanessa Sochat"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
__copyright__ = "Copyright 2021-2022, Vanessa Sochat"
__copyright__ = "Copyright 2022, Vanessa Sochat"

__license__ = "MPL 2.0"

import shpc.utils
from shpc.logger import logger


def main(args, parser, extra, subparser):

from shpc.main import get_client

shpc.utils.ensure_no_extra(extra)

cli = get_client(
quiet=args.quiet,
settings_file=args.settings_file,
module_sys=args.module_sys,
container_tech=args.container_tech,
)

# Update config settings on the fly
cli.settings.update_params(args.config_params)

# It doesn't make sense to give a module name and --all
if args.reinstall_recipe and args.all:
logger.exit("Conflicting arguments reinstall_recipe and --all, choose one.")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the user knows what the argument name is, at least without looking.

Suggested change
logger.exit("Conflicting arguments reinstall_recipe and --all, choose one.")
logger.exit("Conflicting arguments: choose a single module name to reinstall OR --all.")

# One option must be present
if not args.reinstall_recipe and not args.all:
logger.exit("Missing arguments: provide reinstall_recipe or --all.")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
logger.exit("Missing arguments: provide reinstall_recipe or --all.")
logger.exit("Missing arguments: provide a single module to reinstall OR --all.")

if args.ignore_missing and args.uninstall_missing:
logger.exit(
"Conflicting arguments --ignore-missing and --uninstall-missing, choose one."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"Conflicting arguments --ignore-missing and --uninstall-missing, choose one."
"Conflicting arguments: choose one of --ignore-missing OR --uninstall-missing."

)

# And do the reinstall
cli.reinstall(
args.reinstall_recipe,
when_missing=(
"ignore"
if args.ignore_missing
else "uninstall"
if args.uninstall_missing
else None
),
)
84 changes: 80 additions & 4 deletions shpc/main/modules/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,15 +375,19 @@ def get_module(self, name, container_image=None, keep_path=False):
return module

def install(
self, name, force=False, container_image=None, keep_path=False, **kwargs
self,
name,
allow_reinstall=False,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I mentioned above, let's make this force

Suggested change
allow_reinstall=False,
force=False,

container_image=None,
keep_path=False,
**kwargs
):
"""
Given a unique resource identifier, install a recipe.

For lmod, this means creating a subfolder in modules, pulling the
container to it, and writing a module file there. We've already
grabbed the name from docker (which is currently the only supported).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
grabbed the name from docker (which is currently the only supported).
grabbed the name from docker (which is currently the only supported).
A "force" indicates a reinstall will happen.

"force" is currently not used.
"""
# Create a new module
module = self.get_module(
Expand All @@ -393,6 +397,23 @@ def install(
# We always load overrides for an install
module.load_override_file()

# Check previous installations of this module
if os.path.exists(module.module_dir):
if not allow_reinstall:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if not allow_reinstall:
if not force:

logger.exit(
"%s is already installed. Do `shpc reinstall` to proceed with a reinstallation."
% module.tagged_name
)
logger.info("%s is already installed. Reinstalling." % module.tagged_name)
# Don't explicitly remove the container, since we still need it,
# though it may still happen if shpc is configured to store
# containers and modules in the same directory
self._uninstall(
module.module_dir,
self.settings.module_base,
"$module_base/%s" % module.name,
)

# Create the module and container directory
utils.mkdirp([module.module_dir, module.container_dir])

Expand Down Expand Up @@ -421,12 +442,12 @@ def install(
logger.info("Module %s was created." % module.tagged_name)
return module.container_path

def view_install(self, view_name, name, force=False, container_image=None):
def view_install(self, view_name, name, force=False):
"""
Install a module in a view. The module must already be installed.
Set "force" to True to allow overwriting existing symlinks.
"""
module = self.get_module(name, container_image=container_image)
module = self.get_module(name)

# A view is a symlink under views_base/$view/$module
if view_name not in self.views:
Expand All @@ -440,3 +461,58 @@ def view_install(self, view_name, name, force=False, container_image=None):
# Don't continue if it exists, unless force is True
view.confirm_install(module.module_dir, force=force)
view.install(module.module_dir)

def reinstall(self, name, when_missing=None):
"""
Reinstall the module, or all modules
"""
if name:
module_name, _, version = name.partition(":")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some redundancy here with functionality - could we parse the name using our Module class?

# Find all the versions currently installed
installed_modules = self._get_module_lookup(
self.settings.module_base, self.modulefile, module_name
)
if (module_name not in installed_modules) or (
version and version not in installed_modules[module_name]
):
logger.exit("%s is not installed. Nothing to reinstall." % name)
versions = [version] if version else installed_modules[module_name]
# Reinstall the required version(s) one by one
for version in versions:
self._reinstall(module_name, version, when_missing)
else:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay I see what you are doing - without a name we hit the else and this is "reinstall all." I will think more about this one - I don't have a better design idea at 5:30am! But not reading the code, it's not obvious this function handles single / all use cases (beyond the comment in the docstring). I think given that we are ultimately looping through a list of modules (and the versions come from a lookup) or would otherwise just be None) it might be nice to have logic to populate the list of modules and versions, and one common loop through them to reinstall. The other option is to have two clearly distinguished functions, but that might be overkill because we already have two!

# Reinstall everything that is currently installed
installed_modules = self._get_module_lookup(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I wonder if there is some way to simplify the logic here (and not call the function in both blocks but use a shared call?) See duplicate of (almost) identical code always makes me pause!

self.settings.module_base, self.modulefile
)
for module_name, versions in installed_modules.items():
for version in versions:
self._reinstall(module_name, version, when_missing)

def _reinstall(self, module_name, version, when_missing):
"""
Reinstall (and possibly upgrade) all the current modules, possibly filtered by pattern.
"""
result = self.registry.find(module_name)
if result:
config = container.ContainerConfig(result)
if version in config.tags:
return self.install(module_name + ":" + version, allow_reinstall=True)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return self.install(module_name + ":" + version, allow_reinstall=True)
return self.install(module_name + ":" + version, force=True)

else:
missing = module_name + ":" + version
else:
missing = module_name
Comment on lines +501 to +504
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The double nested if - is there a better way to write this? I think we can just plop the entire thing (name and version) into missing? And since we return above we don't need the elses.

Suggested change
else:
missing = module_name + ":" + version
else:
missing = module_name
missing = (module_name + ":" + version) if version else module_name


if when_missing:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we collapse these into a single if elif elif, e.g.,:

if not when_missing:
     ...
elif when_missing == "ignore":
    ...
elif when_missing == "uninstall":

else:
   # maybe mention whatever they provided isn't a valid option?

if when_missing == "ignore":
logger.info(
"%s is not in the Registry any more. Ignoring as instructed."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"%s is not in the Registry any more. Ignoring as instructed."
"%s is not in the registry any more. Ignoring as instructed."

% missing
)
elif when_missing == "uninstall":
self.uninstall(module_name + ":" + version, force=True)
else:
logger.exit(
"%s is not in the Registry any more. Add --uninstall-missing or --ignore-missing."
% missing
)
40 changes: 38 additions & 2 deletions shpc/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,6 @@ def test_install_get(tmp_path, module_sys, module_file, container_tech, remote):

assert client.get("python:3.9.2-alpine")

client.install("python:3.9.2-alpine")


@pytest.mark.parametrize(
"module_sys,module_file,remote",
Expand Down Expand Up @@ -377,3 +375,41 @@ def test_add(tmp_path, module_sys, remote):
client.get("dinosaur/salad:latest")
client.install("dinosaur/salad:latest")
assert client.get("dinosaur/salad:latest")


@pytest.mark.parametrize(
muffato marked this conversation as resolved.
Show resolved Hide resolved
"module_sys,module_file,container_tech,remote",
[
("lmod", "module.lua", "singularity", False),
("lmod", "module.lua", "podman", False),
("tcl", "module.tcl", "singularity", False),
("tcl", "module.tcl", "podman", False),
("lmod", "module.lua", "singularity", True),
("lmod", "module.lua", "podman", True),
("tcl", "module.tcl", "singularity", True),
("tcl", "module.tcl", "podman", True),
],
)
def test_reinstall(tmp_path, module_sys, module_file, container_tech, remote):
"""
Test install and reinstall
"""
client = init_client(str(tmp_path), module_sys, container_tech, remote=remote)

# Install known tag
client.install("python:3.9.2-alpine")
module_dir = os.path.join(client.settings.module_base, "python", "3.9.2-alpine")
env_file = os.path.join(module_dir, client.settings.environment_file)
dummy = os.path.join(module_dir, "dummy.sh")
# Ensure the content is initially as expected
assert os.path.exists(env_file)
assert not os.path.exists(dummy)
# Modify it
os.unlink(env_file)
shpc.utils.write_file(module_file, "")
assert not os.path.exists(env_file)
assert os.path.exists(dummy)
# The reinstallation should restore everything
client.install("python:3.9.2-alpine", force=True)
assert os.path.exists(env_file)
assert not os.path.exists(dummy)