From 3d7e02251d948623cefe18e126dbe54bf8800844 Mon Sep 17 00:00:00 2001
From: Jusong Yu <jusong.yeu@gmail.com>
Date: Tue, 3 Oct 2023 23:15:57 +0200
Subject: [PATCH 1/3] Check the image is outdated and pop warning to pull the
 latest

---
 aiidalab_launch/__main__.py | 15 ++++++++++++++-
 aiidalab_launch/util.py     | 23 +++++++++++++++++++++++
 tests/test_util.py          | 14 +++++++++++++-
 3 files changed, 50 insertions(+), 2 deletions(-)

diff --git a/aiidalab_launch/__main__.py b/aiidalab_launch/__main__.py
index a41fe6d..cd06be3 100644
--- a/aiidalab_launch/__main__.py
+++ b/aiidalab_launch/__main__.py
@@ -21,7 +21,13 @@
 from .core import LOGGER
 from .instance import AiidaLabInstance
 from .profile import DEFAULT_IMAGE, DEFAULT_PORT, Profile
-from .util import confirm_with_value, get_latest_version, spinner, webbrowser_available
+from .util import (
+    confirm_with_value,
+    get_latest_version,
+    image_is_latest,
+    spinner,
+    webbrowser_available,
+)
 from .version import __version__
 
 MSG_MOUNT_POINT_CONFLICT = """Warning: There is at least one other running
@@ -350,6 +356,13 @@ async def _async_start(
         # use local image
         msg = f"Using local image '{profile.image}'."
 
+        # check if local image is outdated and pull latest version if so
+        if not image_is_latest(instance.client, profile.image):
+            click.secho(
+                "Warning! Local image is outdated, please run with --pull to update.",
+                fg="yellow",
+            )
+
     if instance.image is None:
         raise click.ClickException(
             f"Unable to find image '{profile.image}'. "
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/test_util.py b/tests/test_util.py
index d2ea15d..1f2434a 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,14 @@ 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")
+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)

From 6dbb0c477ab4a877489986db3464259e3ac68246 Mon Sep 17 00:00:00 2001
From: Jusong Yu <jusong.yeu@gmail.com>
Date: Tue, 3 Oct 2023 23:53:41 +0200
Subject: [PATCH 2/3] monkeypatch and test the warning pop

---
 aiidalab_launch/__main__.py | 12 ++++--------
 tests/test_cli.py           | 16 +++++++++++++++-
 2 files changed, 19 insertions(+), 9 deletions(-)

diff --git a/aiidalab_launch/__main__.py b/aiidalab_launch/__main__.py
index cd06be3..d0374ce 100644
--- a/aiidalab_launch/__main__.py
+++ b/aiidalab_launch/__main__.py
@@ -21,13 +21,7 @@
 from .core import LOGGER
 from .instance import AiidaLabInstance
 from .profile import DEFAULT_IMAGE, DEFAULT_PORT, Profile
-from .util import (
-    confirm_with_value,
-    get_latest_version,
-    image_is_latest,
-    spinner,
-    webbrowser_available,
-)
+from .util import confirm_with_value, get_latest_version, spinner, webbrowser_available
 from .version import __version__
 
 MSG_MOUNT_POINT_CONFLICT = """Warning: There is at least one other running
@@ -353,11 +347,13 @@ 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}'."
 
         # check if local image is outdated and pull latest version if so
-        if not image_is_latest(instance.client, profile.image):
+        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",
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

From 42f086787c8d6502000939c8779c453c6bd965ed Mon Sep 17 00:00:00 2001
From: Jusong Yu <jusong.yeu@gmail.com>
Date: Fri, 6 Oct 2023 10:25:32 +0200
Subject: [PATCH 3/3] f-d

---
 aiidalab_launch/__main__.py |  6 ------
 tests/conftest.py           | 13 +++++++++++++
 tests/test_util.py          | 17 +++++++++++++++++
 3 files changed, 30 insertions(+), 6 deletions(-)

diff --git a/aiidalab_launch/__main__.py b/aiidalab_launch/__main__.py
index d0374ce..bf15c63 100644
--- a/aiidalab_launch/__main__.py
+++ b/aiidalab_launch/__main__.py
@@ -359,12 +359,6 @@ async def _async_start(
                 fg="yellow",
             )
 
-    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 the container configuration has changed.
     if instance.container:
         configuration_changed = any(instance.configuration_changes())
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_util.py b/tests/test_util.py
index 1f2434a..60ee483 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -17,6 +17,7 @@ def test_get_latest_version_timeout(mock_pypi_request_timeout):
 
 
 @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
@@ -25,3 +26,19 @@ def test_image_is_latest(docker_client):
 
     # 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)