Skip to content

Commit 0a869e0

Browse files
committed
feat: custom docker image namespace
Signed-off-by: thxCode <[email protected]>
1 parent 13e0dc5 commit 0a869e0

File tree

7 files changed

+312
-7
lines changed

7 files changed

+312
-7
lines changed

gpustack_runtime/deployer/__types__.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,13 +1023,12 @@ def validate_and_default(self):
10231023
and envs.GPUSTACK_RUNTIME_DEPLOY_DEFAULT_REGISTRY
10241024
not in ["docker.io", "index.docker.io"]
10251025
):
1026+
image_registry = envs.GPUSTACK_RUNTIME_DEPLOY_DEFAULT_NAMESPACE
10261027
image_split = c.image.split("/")
10271028
if len(image_split) == 1:
1028-
c.image = f"{envs.GPUSTACK_RUNTIME_DEPLOY_DEFAULT_REGISTRY}/library/{c.image}"
1029+
c.image = f"{image_registry}/library/{c.image}"
10291030
elif len(image_split) == 2:
1030-
c.image = (
1031-
f"{envs.GPUSTACK_RUNTIME_DEPLOY_DEFAULT_REGISTRY}/{c.image}"
1032-
)
1031+
c.image = f"{image_registry}/{c.image}"
10331032
# Correct runner image if needed.
10341033
if envs.GPUSTACK_RUNTIME_DEPLOY_CORRECT_RUNNER_IMAGE:
10351034
c.image, ok = correct_runner_image(c.image)

gpustack_runtime/deployer/__utils__.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,3 +651,127 @@ def bytes_to_human_readable(size_in_bytes: int) -> str:
651651
if size_in_bytes >= _KiB:
652652
return f"{size_in_bytes / _KiB:.2f} KiB"
653653
return f"{size_in_bytes} B"
654+
655+
656+
def replace_image_with(
657+
image: str,
658+
registry: str | None = None,
659+
namespace: str | None = None,
660+
repository: str | None = None,
661+
) -> str:
662+
"""
663+
Replace the registry, namespace, and repository of a Docker image string.
664+
665+
The given image string is parsed into its components (registry, namespace, repository, tag),
666+
and the specified components are replaced with the provided values.
667+
668+
The format of a Docker image string is:
669+
[registry/][namespace/]repository[:tag|@digest]
670+
671+
Args:
672+
image:
673+
The original Docker image string.
674+
registry:
675+
The new registry to use. If None, keep the original registry.
676+
namespace:
677+
The new namespace to use. If None, keep the original namespace.
678+
repository:
679+
The new repository to use. If None, keep the original repository.
680+
681+
Returns:
682+
The modified Docker image string.
683+
684+
"""
685+
if not image or (not registry and not namespace and not repository):
686+
return image
687+
688+
registry = registry.strip() if registry else None
689+
namespace = namespace.strip() if namespace else None
690+
repository = repository.strip() if repository else None
691+
692+
image_reg, image_ns, image_repo, image_tag = (
693+
None,
694+
None,
695+
None,
696+
None,
697+
)
698+
image_rest = image.strip()
699+
700+
# Get tag.
701+
parts = image_rest.rsplit("@", maxsplit=1)
702+
if len(parts) == 2:
703+
image_rest, image_tag = parts
704+
else:
705+
parts = image_rest.rsplit(":", maxsplit=1)
706+
if len(parts) == 2 and "/" not in parts[1]:
707+
image_rest, image_tag = parts
708+
if not image_rest:
709+
return image
710+
711+
# Get repository.
712+
parts = image_rest.rsplit("/", maxsplit=1)
713+
if len(parts) == 2:
714+
image_rest, image_repo = parts
715+
else:
716+
image_rest, image_repo = None, image_rest
717+
718+
# Get namespace.
719+
if image_rest:
720+
parts = image_rest.rsplit("/", maxsplit=1)
721+
if len(parts) == 2:
722+
image_reg, image_ns = parts
723+
else:
724+
image_reg, image_ns = None, image_rest
725+
726+
return make_image_with(
727+
repository=repository or image_repo,
728+
registry=registry or image_reg,
729+
namespace=namespace or image_ns,
730+
tag=image_tag,
731+
)
732+
733+
734+
def make_image_with(
735+
repository: str,
736+
registry: str | None = None,
737+
namespace: str | None = None,
738+
tag: str | None = None,
739+
) -> str:
740+
"""
741+
Make a Docker image string from the given registry, namespace, repository, and tag.
742+
743+
The format of a Docker image string is:
744+
[registry/][namespace/]repository[:tag|@digest]
745+
746+
Args:
747+
repository:
748+
The repository name.
749+
registry:
750+
The registry to use. If None, no registry will be used.
751+
namespace:
752+
The namespace to use. If None, no namespace will be used.
753+
tag:
754+
The tag to use. If None, no tag will be used.
755+
756+
Returns:
757+
The Docker image string.
758+
759+
"""
760+
if not repository or (not registry and not namespace and not tag):
761+
return repository
762+
763+
image = ""
764+
if registry:
765+
image += f"{registry}/"
766+
if namespace:
767+
image += f"{namespace}/"
768+
elif registry:
769+
image += "library/"
770+
image += repository
771+
if not tag:
772+
return image
773+
if tag.startswith("sha256:"):
774+
image += f"@{tag}"
775+
else:
776+
image += f":{tag}"
777+
return image

gpustack_runtime/deployer/docker.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
WorkloadStatusOperation,
4545
WorkloadStatusStateEnum,
4646
)
47-
from .__utils__ import _MiB, bytes_to_human_readable, safe_json
47+
from .__utils__ import _MiB, bytes_to_human_readable, replace_image_with, safe_json
4848

4949
if TYPE_CHECKING:
5050
from collections.abc import Callable, Generator
@@ -138,6 +138,17 @@ def validate_and_default(self):
138138
# Default and validate in the base class.
139139
super().validate_and_default()
140140

141+
# Adjust default image namespace if needed.
142+
if namespace := envs.GPUSTACK_RUNTIME_DEPLOY_DEFAULT_NAMESPACE:
143+
self.pause_image = replace_image_with(
144+
image=self.pause_image,
145+
namespace=namespace,
146+
)
147+
self.unhealthy_restart_image = replace_image_with(
148+
image=self.unhealthy_restart_image,
149+
namespace=namespace,
150+
)
151+
141152

142153
@dataclass_json
143154
@dataclass

gpustack_runtime/envs.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@
6868
If not set, it should be "docker.io".
6969
If the image name already contains a registry, this setting will be ignored.
7070
"""
71+
GPUSTACK_RUNTIME_DEPLOY_DEFAULT_NAMESPACE: str | None = None
72+
"""
73+
Namespace for default runner images.
74+
If not set, it should be "gpustack".
75+
"""
7176
GPUSTACK_RUNTIME_DEPLOY_DEFAULT_REGISTRY_USERNAME: str | None = None
7277
"""
7378
Username for the default container registry.
@@ -282,7 +287,15 @@
282287
"Auto",
283288
),
284289
"GPUSTACK_RUNTIME_DEPLOY_DEFAULT_REGISTRY": lambda: trim_str(
285-
getenv("GPUSTACK_RUNTIME_DEPLOY_DEFAULT_REGISTRY"),
290+
getenvs(
291+
keys=[
292+
"GPUSTACK_RUNTIME_DEPLOY_DEFAULT_REGISTRY",
293+
"GPUSTACK_SYSTEM_DEFAULT_CONTAINER_REGISTRY", # Compatible with gpustack/gpustack.
294+
],
295+
),
296+
),
297+
"GPUSTACK_RUNTIME_DEPLOY_DEFAULT_NAMESPACE": lambda: trim_str(
298+
getenv("GPUSTACK_RUNTIME_DEPLOY_DEFAULT_NAMESPACE"),
286299
),
287300
"GPUSTACK_RUNTIME_DEPLOY_DEFAULT_REGISTRY_USERNAME": lambda: trim_str(
288301
getenv("GPUSTACK_RUNTIME_DEPLOY_DEFAULT_REGISTRY_USERNAME"),
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
[
2+
[
3+
"1: empty",
4+
{
5+
"repository": ""
6+
},
7+
""
8+
],
9+
[
10+
"2: empty",
11+
{
12+
"repository": "nginx"
13+
},
14+
"nginx"
15+
],
16+
[
17+
"nginx:latest",
18+
{
19+
"repository": "nginx",
20+
"tag": "latest"
21+
},
22+
"nginx:latest"
23+
],
24+
[
25+
"website.com/abc/library/nginx",
26+
{
27+
"repository": "nginx",
28+
"registry": "website.com/abc"
29+
},
30+
"website.com/abc/library/nginx"
31+
],
32+
[
33+
"xyz/nginx",
34+
{
35+
"repository": "nginx",
36+
"namespace": "xyz"
37+
},
38+
"xyz/nginx"
39+
],
40+
[
41+
"1: website.com/abc/xyz/nginx:v0.1.0",
42+
{
43+
"repository": "nginx",
44+
"registry": "website.com/abc",
45+
"namespace": "xyz",
46+
"tag": "v0.1.0"
47+
},
48+
"website.com/abc/xyz/nginx:v0.1.0"
49+
],
50+
[
51+
"2: website.com/abc/xyz/nginx:v0.1.0",
52+
{
53+
"repository": "nginx",
54+
"registry": "website.com",
55+
"namespace": "abc/xyz",
56+
"tag": "v0.1.0"
57+
},
58+
"website.com/abc/xyz/nginx:v0.1.0"
59+
],
60+
[
61+
"xyz/nginx@sha256:abcdef1234567890",
62+
{
63+
"repository": "nginx",
64+
"namespace": "xyz",
65+
"tag": "sha256:abcdef1234567890"
66+
},
67+
"xyz/nginx@sha256:abcdef1234567890"
68+
]
69+
]
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
[
2+
[
3+
"1: empty",
4+
{
5+
"image": ""
6+
},
7+
""
8+
],
9+
[
10+
"2: empty",
11+
{
12+
"image": "nginx"
13+
},
14+
"nginx"
15+
],
16+
[
17+
"nginx",
18+
{
19+
"image": "nginx",
20+
"registry": "quay.io"
21+
},
22+
"quay.io/library/nginx"
23+
],
24+
[
25+
"nginxx:latest",
26+
{
27+
"image": "nginxx:latest",
28+
"registry": "quay.io",
29+
"repository": "nginx"
30+
},
31+
"quay.io/library/nginx:latest"
32+
],
33+
[
34+
"xyz/nginx@sha256:abcdef1234567890",
35+
{
36+
"image": "xyz/nginx@sha256:abcdef1234567890",
37+
"namespace": "lmn"
38+
},
39+
"lmn/nginx@sha256:abcdef1234567890"
40+
],
41+
[
42+
"docker.io/library/redis:6.0",
43+
{
44+
"image": "docker.io/library/redis:6.0",
45+
"registry": "myregistry.com/mysuperrepo",
46+
"repository": "myredis"
47+
},
48+
"myregistry.com/mysuperrepo/library/myredis:6.0"
49+
],
50+
[
51+
"docker.io/abc/xyz/redis:6.0",
52+
{
53+
"image": "docker.io/abc/xyz/redis:6.0",
54+
"namespace": "lmn"
55+
},
56+
"docker.io/abc/lmn/redis:6.0"
57+
]
58+
]

tests/gpustack_runtime/deployer/test_utils.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import pytest
22
from fixtures import load
33

4-
from gpustack_runtime.deployer.__utils__ import compare_versions, correct_runner_image
4+
from gpustack_runtime.deployer.__utils__ import (
5+
compare_versions,
6+
correct_runner_image,
7+
make_image_with,
8+
replace_image_with,
9+
)
510

611

712
@pytest.mark.parametrize(
@@ -28,3 +33,29 @@ def test_correct_runner_image(name, kwargs, expected):
2833
assert list(actual) == expected, (
2934
f"case {name} expected {expected}, but got {actual} for kwargs: {kwargs}"
3035
)
36+
37+
38+
@pytest.mark.parametrize(
39+
"name, kwargs, expected",
40+
load(
41+
"test_replace_image_with.json",
42+
),
43+
)
44+
def test_replace_image_with(name, kwargs, expected):
45+
actual = replace_image_with(**kwargs)
46+
assert actual == expected, (
47+
f"case {name} expected {expected}, but got {actual} for kwargs: {kwargs}"
48+
)
49+
50+
51+
@pytest.mark.parametrize(
52+
"name, kwargs, expected",
53+
load(
54+
"test_make_image_with.json",
55+
),
56+
)
57+
def test_make_image_with(name, kwargs, expected):
58+
actual = make_image_with(**kwargs)
59+
assert actual == expected, (
60+
f"case {name} expected {expected}, but got {actual} for kwargs: {kwargs}"
61+
)

0 commit comments

Comments
 (0)