Skip to content

Commit 90654c8

Browse files
authored
NO-JIRA: tests(containers): use Skopeo for remote image inspection in tests (#1077)
This commit adds support for remote image inspection using Skopeo, enabling retrieval of image labels without pulling the image locally. Refactored image handling to use a new `Image` dataclass, simplifying metadata storage and access across test functions.
1 parent 28f14c1 commit 90654c8

File tree

6 files changed

+85
-37
lines changed

6 files changed

+85
-37
lines changed

tests/containers/conftest.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import dataclasses
34
import logging
45
import os
56
import platform
@@ -13,7 +14,7 @@
1314
import testcontainers.core.container
1415
import testcontainers.core.docker_client
1516

16-
from tests.containers import docker_utils, utils
17+
from tests.containers import docker_utils, skopeo_utils, utils
1718

1819
if TYPE_CHECKING:
1920
from collections.abc import Callable
@@ -38,6 +39,18 @@
3839
testcontainers.core.config.testcontainers_config.ryuk_privileged = True
3940

4041

42+
@dataclasses.dataclass
43+
class Image:
44+
# only pulled images have an id
45+
id: str | None
46+
name: str
47+
labels: dict[str, str]
48+
49+
@classmethod
50+
def from_docker(cls, image: docker.models.images.Image, name: str):
51+
return Image(id=image.id, name=name, labels=image.labels)
52+
53+
4154
# https://docs.pytest.org/en/latest/reference/reference.html#pytest.hookspec.pytest_addoption
4255
def pytest_addoption(parser: Parser) -> None:
4356
parser.addoption("--image", action="append", default=[], help="Image to use, can be specified multiple times")
@@ -49,20 +62,24 @@ def pytest_generate_tests(metafunc: Metafunc) -> None:
4962
metafunc.parametrize(image.__name__, metafunc.config.getoption("--image"))
5063

5164

52-
def get_image_metadata(image: str) -> docker.models.images.Image:
65+
def get_image_metadata(image: str) -> Image:
5366
client = testcontainers.core.container.DockerClient()
5467
try:
68+
# docker inspect
5569
image_metadata = client.client.images.get(image)
5670
except docker.errors.ImageNotFound:
57-
# todo(jdanek): this means that even when image is to be run remotely (on openshift),
58-
# it has to be pulled locally first so that we can check its metadata
71+
# skopeo inspect
72+
labels = skopeo_utils.get_image_labels(image)
73+
if labels is not None:
74+
return Image(id=None, name=image, labels=labels)
75+
# pull & docker inspect
5976
image_metadata = client.client.images.pull(image)
6077
assert isinstance(image_metadata, docker.models.images.Image)
6178

62-
return image_metadata
79+
return Image.from_docker(image_metadata, name=image)
6380

6481

65-
def skip_if_not_workbench_image(image: str) -> docker.models.images.Image:
82+
def skip_if_not_workbench_image(image: str) -> Image:
6683
image_metadata = get_image_metadata(image)
6784

6885
ide_server_label_fragments = ("-code-server-", "-jupyter-", "-rstudio-")
@@ -74,7 +91,7 @@ def skip_if_not_workbench_image(image: str) -> docker.models.images.Image:
7491
return image_metadata
7592

7693

77-
def skip_if_not_cuda_image(image: str) -> docker.models.images.Image:
94+
def skip_if_not_cuda_image(image: str) -> Image:
7895
image_metadata = get_image_metadata(image)
7996

8097
if "-cuda-" not in image_metadata.labels["name"]:
@@ -83,7 +100,7 @@ def skip_if_not_cuda_image(image: str) -> docker.models.images.Image:
83100
return image_metadata
84101

85102

86-
def skip_if_not_rocm_image(image: str) -> docker.models.images.Image:
103+
def skip_if_not_rocm_image(image: str) -> Image:
87104
image_metadata = get_image_metadata(image)
88105
if "-rocm-" not in image_metadata.labels["name"]:
89106
pytest.skip(f"Image {image} does not have any of '-rocm-' in {image_metadata.labels['name']=}")
@@ -117,7 +134,7 @@ def rocm_workbench_image(workbench_image: str):
117134

118135

119136
@pytest.fixture(scope="function")
120-
def jupyterlab_image(image: str) -> docker.models.images.Image:
137+
def jupyterlab_image(image: str) -> Image:
121138
image_metadata = skip_if_not_workbench_image(image)
122139
if "-jupyter-" not in image_metadata.labels["name"]:
123140
pytest.skip(f"Image {image} does not have '-jupyter-' in {image_metadata.labels['name']=}'")
@@ -126,7 +143,7 @@ def jupyterlab_image(image: str) -> docker.models.images.Image:
126143

127144

128145
@pytest.fixture(scope="function")
129-
def rstudio_image(image: str) -> docker.models.images.Image:
146+
def rstudio_image(image: str) -> Image:
130147
image_metadata = skip_if_not_workbench_image(image)
131148
if not utils.is_rstudio_image(image):
132149
pytest.skip(f"Image {image} does not have '-rstudio-' in {image_metadata.labels['name']=}'")
@@ -135,7 +152,7 @@ def rstudio_image(image: str) -> docker.models.images.Image:
135152

136153

137154
@pytest.fixture(scope="function")
138-
def codeserver_image(image: str) -> docker.models.images.Image:
155+
def codeserver_image(image: str) -> Image:
139156
image_metadata = skip_if_not_workbench_image(image)
140157
if "-code-server-" not in image_metadata.labels["name"]:
141158
pytest.skip(f"Image {image} does not have '-code-server-' in {image_metadata.labels['name']=}'")

tests/containers/skopeo_utils.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import json
2+
import logging
3+
import subprocess
4+
5+
logger = logging.getLogger(__name__)
6+
7+
type JSON = dict[str, JSON] | list[JSON] | str | int | float | bool | None
8+
9+
10+
def get_image_labels(image_name: str) -> JSON | None:
11+
try:
12+
skopeo_command = [
13+
"skopeo",
14+
# "--override-os=linux", "--override-arch=amd64",
15+
"inspect",
16+
"--config",
17+
f"docker://{image_name}",
18+
]
19+
logger.info(f"Attempting remote inspection for {image_name} using skopeo: {' '.join(skopeo_command)}")
20+
result = subprocess.run(skopeo_command, capture_output=True, text=True, check=True, timeout=60)
21+
image_config_json = json.loads(result.stdout)
22+
23+
# Labels can be in image_config_json.config.Labels or image_config_json.Labels (older formats)
24+
labels = image_config_json.get("config", {}).get("Labels")
25+
if labels is None:
26+
labels = image_config_json.get("Labels")
27+
28+
if labels is not None: # Explicitly check for None, as {} is a valid (empty) set of labels
29+
logger.info(f"Skopeo successfully inspected {image_name}. Labels: {labels}")
30+
return labels
31+
else:
32+
logger.warning(f"Skopeo inspection for {image_name} found config but no 'Labels' field.")
33+
return None
34+
35+
except FileNotFoundError:
36+
logger.warning("skopeo command not found. Cannot inspect remote image labels without pulling.")
37+
except subprocess.TimeoutExpired:
38+
logger.warning(f"skopeo inspect for {image_name} timed out.")
39+
except subprocess.CalledProcessError as e:
40+
logger.warning(f"skopeo inspect for {image_name} failed. Command: '{' '.join(e.cmd)}'. Error: {e.stderr}")
41+
except json.JSONDecodeError as e:
42+
logger.warning(f"Failed to parse skopeo JSON output for {image_name}: {e}")
43+
return None

tests/containers/utils.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
1-
import docker.errors
2-
import docker.models.images
3-
import testcontainers.core.container
1+
from tests.containers import conftest
42

53

64
def is_rstudio_image(my_image: str) -> bool:
75
label = "-rstudio-"
86

9-
client = testcontainers.core.container.DockerClient()
10-
try:
11-
image_metadata = client.client.images.get(my_image)
12-
except docker.errors.ImageNotFound:
13-
image_metadata = client.client.images.pull(my_image)
14-
assert isinstance(image_metadata, docker.models.images.Image)
7+
image_metadata = conftest.get_image_metadata(my_image)
158

169
return label in image_metadata.labels["name"]

tests/containers/workbenches/jupyterlab/jupyterlab_test.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING
4-
53
import allure
64
import requests
75

8-
from tests.containers import docker_utils
6+
from tests.containers import conftest, docker_utils
97
from tests.containers.workbenches.workbench_image_test import WorkbenchContainer
108

11-
if TYPE_CHECKING:
12-
import docker.models.images
13-
149

1510
class TestJupyterLabImage:
1611
"""Tests for JupyterLab Workbench images in this repository."""
@@ -19,8 +14,8 @@ class TestJupyterLabImage:
1914

2015
@allure.issue("RHOAIENG-11156")
2116
@allure.description("Check that the HTML for the spinner is contained in the initial page.")
22-
def test_spinner_html_loaded(self, jupyterlab_image: docker.models.images.Image) -> None:
23-
container = WorkbenchContainer(image=jupyterlab_image, user=4321, group_add=[0])
17+
def test_spinner_html_loaded(self, jupyterlab_image: conftest.Image) -> None:
18+
container = WorkbenchContainer(image=jupyterlab_image.name, user=4321, group_add=[0])
2419
# if no env is specified, the image will run
2520
# > 4321 3334 3319 0 10:36 pts/0 00:00:01 /mnt/rosetta /opt/app-root/bin/python3.11 /opt/app-root/bin/jupyter-lab
2621
# > --ServerApp.root_dir=/opt/app-root/src --ServerApp.ip= --ServerApp.allow_origin=* --ServerApp.open_browser=False

tests/containers/workbenches/rstudio/rstudio_test.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,10 @@
1111
import allure
1212
import pytest
1313

14-
from tests.containers import docker_utils
14+
from tests.containers import conftest, docker_utils
1515
from tests.containers.workbenches.workbench_image_test import WorkbenchContainer
1616

1717
if TYPE_CHECKING:
18-
import docker.models.images
1918
import pytest_subtests
2019

2120

@@ -25,7 +24,7 @@ class TestRStudioImage:
2524
APP_ROOT_HOME = "/opt/app-root/src"
2625

2726
@allure.issue("RHOAIENG-17256")
28-
def test_rmd_to_pdf_rendering(self, rstudio_image: docker.models.images.Image) -> None:
27+
def test_rmd_to_pdf_rendering(self, rstudio_image: conftest.Image) -> None:
2928
"""
3029
References:
3130
https://stackoverflow.com/questions/40563479/relationship-between-r-markdown-knitr-pandoc-and-bookdown
@@ -36,7 +35,7 @@ def test_rmd_to_pdf_rendering(self, rstudio_image: docker.models.images.Image) -
3635
"ISSUE-957, RHOAIENG-17256(comments): RStudio workbench on RHEL does not come with knitr preinstalled"
3736
)
3837

39-
container = WorkbenchContainer(image=rstudio_image, user=1000, group_add=[0])
38+
container = WorkbenchContainer(image=rstudio_image.name, user=1000, group_add=[0])
4039
try:
4140
container.start(wait_for_readiness=False)
4241

@@ -108,12 +107,12 @@ def test_rmd_to_pdf_rendering(self, rstudio_image: docker.models.images.Image) -
108107
docker_utils.NotebookContainer(container).stop(timeout=0)
109108

110109
@allure.issue("RHOAIENG-23584")
111-
def test_arbitrary_env_propagates_unchanged(self, rstudio_image: str) -> None:
110+
def test_arbitrary_env_propagates_unchanged(self, rstudio_image: conftest.Image) -> None:
112111
"""
113112
Checks that environment variables are propagated into the RStudio environment.
114113
"""
115114

116-
container = WorkbenchContainer(image=rstudio_image, user=1000, group_add=[0])
115+
container = WorkbenchContainer(image=rstudio_image.name, user=1000, group_add=[0])
117116
container.with_env("SOME_VARIABLE", "Some Value")
118117

119118
try:
@@ -127,7 +126,9 @@ def test_arbitrary_env_propagates_unchanged(self, rstudio_image: str) -> None:
127126
docker_utils.NotebookContainer(container).stop(timeout=0)
128127

129128
@allure.issue("RHOAIENG-16604")
130-
def test_http_proxy_env_propagates(self, rstudio_image: str, subtests: pytest_subtests.plugin.SubTests) -> None:
129+
def test_http_proxy_env_propagates(
130+
self, rstudio_image: conftest.Image, subtests: pytest_subtests.plugin.SubTests
131+
) -> None:
131132
"""
132133
This checks that the lowercased proxy configuration is propagated into the RStudio
133134
environment so that the appropriate values are then accepted and followed.
@@ -144,7 +145,7 @@ class TestCase(NamedTuple):
144145
TestCase("NO_PROXY", "no_proxy", "google.com"),
145146
]
146147

147-
container = WorkbenchContainer(image=rstudio_image, user=1000, group_add=[0])
148+
container = WorkbenchContainer(image=rstudio_image.name, user=1000, group_add=[0])
148149
for tc in test_cases:
149150
container.with_env(tc.name, tc.value)
150151

tests/containers/workbenches/workbench_image_test.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from typing import TYPE_CHECKING
1212

1313
import docker.errors
14-
import docker.models.images
1514
import docker.types
1615
import pytest
1716
import testcontainers.core.container

0 commit comments

Comments
 (0)