diff --git a/aiidalab_launch/__main__.py b/aiidalab_launch/__main__.py index a41fe6d..bf15c63 100644 --- a/aiidalab_launch/__main__.py +++ b/aiidalab_launch/__main__.py @@ -347,14 +347,17 @@ async def _async_start( with spinner(msg): instance.pull() else: + from aiidalab_launch import util + # use local image msg = f"Using local image '{profile.image}'." - if instance.image is None: - raise click.ClickException( - f"Unable to find image '{profile.image}'. " - "Try to use '--pull' to pull the image prior to start." - ) + # check if local image is outdated and pull latest version if so + if not util.image_is_latest(instance.client, profile.image): + click.secho( + "Warning! Local image is outdated, please run with --pull to update.", + fg="yellow", + ) # Check if the container configuration has changed. if instance.container: diff --git a/aiidalab_launch/util.py b/aiidalab_launch/util.py index 2ca6423..20b738b 100644 --- a/aiidalab_launch/util.py +++ b/aiidalab_launch/util.py @@ -225,3 +225,26 @@ def get_docker_env(container: docker.models.containers.Container, env_name: str) except KeyError: pass raise KeyError(env_name) + + +def image_is_latest(docker_client, image: str): + """Check if the local image has the same digest as the image + on remote registry. + """ + try: + local_image = docker_client.images.get(image) + except docker.errors.ImageNotFound: + return False + + try: + remote_image = docker_client.images.get_registry_data(image) + except docker.errors.APIError: + return False + + # There is no need to check creation date of the image, since the once + # there is a new image with the same tag, the id will be different. + # We can not use image id, see https://windsock.io/explaining-docker-image-ids/ + local_digest = local_image.attrs.get("RepoDigests")[0].split("@")[-1] + remote_digest = remote_image.attrs.get("Descriptor", {}).get("digest") + + return local_digest == remote_digest diff --git a/tests/conftest.py b/tests/conftest.py index 93c1cc4..0474614 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,6 +57,19 @@ def docker_client(): pytest.skip("docker not available") +@pytest.fixture(scope="function") +def remove_created_images(docker_client): + """Remove all images created by the tests.""" + images = docker_client.images.list() + yield + for image in docker_client.images.list(): + if image not in images: + try: + image.remove() + except docker.errors.APIError: + pass + + @pytest.fixture(autouse=True) def _select_default_image(monkeypatch_session, pytestconfig): _default_image = pytestconfig.getoption("default_image") diff --git a/tests/test_cli.py b/tests/test_cli.py index 3bd7697..ebe8fa3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -208,7 +208,7 @@ def test_remove_running_profile(self): @pytest.mark.slow @pytest.mark.trylast class TestInstanceLifecycle: - def test_start_stop_reset(self, instance, docker_client, caplog): + def test_start_stop_reset(self, instance, docker_client, caplog, monkeypatch): caplog.set_level(logging.DEBUG) def get_volume(volume_name): @@ -260,6 +260,20 @@ def assert_status_down(): assert result.exit_code == 0 assert_status_up() + # test the warning message of image not the latest is not raised + assert "Warning!" not in result.output.strip() + + # Then by monkeypatching the image_is_latest function, we can test that + # the warning message is raised + def image_is_latest(docker_client, image_name): + return False + + monkeypatch.setattr("aiidalab_launch.util.image_is_latest", image_is_latest) + result: Result = runner.invoke( + cli.cli, ["start", "--no-browser", "--no-pull", "--wait=300"] + ) + assert "Warning!" in result.output.strip() + # Restart instance. # TODO: This test is currently disabled, because it is too flaky. For # a currently unknown reason, the docker client will not be able to diff --git a/tests/test_util.py b/tests/test_util.py index d2ea15d..60ee483 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,8 +1,9 @@ from time import time +import pytest from packaging.version import parse -from aiidalab_launch.util import get_latest_version +from aiidalab_launch.util import get_latest_version, image_is_latest def test_get_latest_version(mock_pypi_request): @@ -13,3 +14,31 @@ def test_get_latest_version_timeout(mock_pypi_request_timeout): start = time() assert get_latest_version() is None assert (time() - start) < 0.5 + + +@pytest.mark.usefixtures("enable_docker_pull") +@pytest.mark.usefixtures("remove_created_images") +def test_image_is_latest(docker_client): + """Test that the latest version is identified correctly.""" + # download the alpine image for testing + image_name = "alpine:latest" + docker_client.images.pull(image_name) + + # check that the image is identified as latest + assert image_is_latest(docker_client, image_name) + + +@pytest.mark.usefixtures("enable_docker_pull") +@pytest.mark.usefixtures("remove_created_images") +def test_image_is_not_latest(docker_client): + """Test that the outdate version is identified correctly and will ask for pull the latest.""" + # download the alpine image for testing + old_image_name = "alpine:2.6" + latest_image_name = "alpine:latest" + + # pull the old image and retag it as latest to mock the outdated image + old_image = docker_client.images.pull(old_image_name) + old_image.tag(latest_image_name) + + # check that the image is identified as latest + assert not image_is_latest(docker_client, latest_image_name)