Skip to content

Commit 922b8ca

Browse files
author
Miłosz Skaza
authored
Support remote images (#143)
* support remote images in challenge deployment * add registry:// prefix to mark images as remote
1 parent ff5c8a9 commit 922b8ca

File tree

10 files changed

+217
-46
lines changed

10 files changed

+217
-46
lines changed

ctfcli/core/challenge.py

+49-7
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import subprocess
33
from os import PathLike
44
from pathlib import Path
5-
from typing import Any, Dict, List, Tuple, Union
5+
from typing import Any, Dict, List, Optional, Tuple, Union
66

77
import click
88
import yaml
@@ -140,16 +140,58 @@ def __init__(self, challenge_yml: Union[str, PathLike], overrides=None):
140140
# API is not initialized before running an API-related operation, but should be reused later
141141
self._api = None
142142

143-
# Set Image to None if the challenge does not provide one
144-
self.image = None
145-
146-
# Get name and a build path for the image if the challenge provides one
147-
if self.get("image"):
148-
self.image = Image(slugify(self["name"]), self.challenge_directory / self["image"])
143+
# Assign an image if the challenge provides one, otherwise this will be set to None
144+
self.image = self._process_challenge_image(self.get("image"))
149145

150146
def __str__(self):
151147
return self["name"]
152148

149+
def _process_challenge_image(self, challenge_image: Optional[str]) -> Optional[Image]:
150+
if not challenge_image:
151+
return None
152+
153+
# Check if challenge_image is explicitly marked with registry:// prefix
154+
if challenge_image.startswith("registry://"):
155+
challenge_image = challenge_image.replace("registry://", "")
156+
return Image(challenge_image)
157+
158+
# Check if it's a library image
159+
if challenge_image.startswith("library/"):
160+
return Image(f"docker.io/{challenge_image}")
161+
162+
# Check if it defines a known registry
163+
known_registries = [
164+
"docker.io",
165+
"gcr.io",
166+
"ecr.aws",
167+
"ghcr.io",
168+
"azurecr.io",
169+
"registry.digitalocean.com",
170+
"registry.gitlab.com",
171+
"registry.ctfd.io",
172+
]
173+
for registry in known_registries:
174+
if registry in challenge_image:
175+
return Image(challenge_image)
176+
177+
# Check if it's a path to dockerfile to be built
178+
if (self.challenge_directory / challenge_image / "Dockerfile").exists():
179+
return Image(slugify(self["name"]), self.challenge_directory / self["image"])
180+
181+
# Check if it's a local pre-built image
182+
if (
183+
subprocess.call(
184+
["docker", "inspect", challenge_image], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
185+
)
186+
== 0
187+
):
188+
return Image(challenge_image)
189+
190+
# If the image is set, but we fail to determine whether it's local / remote - raise an exception
191+
raise InvalidChallengeFile(
192+
f"Challenge file at {self.challenge_file_path} defines an image, but it couldn't be resolved"
193+
)
194+
153195
def _load_challenge_id(self):
154196
remote_challenges = self.load_installed_challenges()
155197
if not remote_challenges:

ctfcli/core/deployment/cloud.py

+10-6
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def __init__(self, *args, **kwargs):
2323
# Do not fail here if challenge does not provide an image
2424
# rather return a failed deployment result during deploy
2525
if self.challenge.get("image"):
26-
self.image_name = self.challenge.image.name
26+
self.image_name = self.challenge.image.basename
2727

2828
def deploy(self, skip_login=False, *args, **kwargs) -> DeploymentResult:
2929
# Check whether challenge defines image
@@ -40,11 +40,15 @@ def deploy(self, skip_login=False, *args, **kwargs) -> DeploymentResult:
4040
# Get or create Image in CTFd
4141
image_data = self._get_or_create_image()
4242

43-
# Build new / initial version of the image
44-
image_name = self.challenge.image.build()
45-
if not image_name:
46-
click.secho("Could not build the image. Please check docker output above.", fg="red")
47-
return DeploymentResult(False)
43+
# Build or Pull / Update the configured image
44+
if self.challenge.image.built:
45+
if not self.challenge.image.pull():
46+
click.secho("Could not pull the image. Please check docker output above.", fg="red")
47+
return DeploymentResult(False)
48+
else:
49+
if not self.challenge.image.build():
50+
click.secho("Could not build the image. Please check docker output above.", fg="red")
51+
return DeploymentResult(False)
4852

4953
if skip_login:
5054
click.secho(

ctfcli/core/deployment/registry.py

+12-6
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def deploy(self, skip_login=False, *args, **kwargs) -> DeploymentResult:
2929
# e.g. registry.example.com/test-project/challenge-image-name
3030
# challenge image name is appended to the host provided for the deployment
3131
host_url = urlparse(self.host)
32-
location = f"{host_url.netloc}{host_url.path.rstrip('/')}/{self.challenge.image.name}"
32+
location = f"{host_url.netloc}{host_url.path.rstrip('/')}/{self.challenge.image.basename}"
3333

3434
if skip_login:
3535
click.secho(
@@ -57,10 +57,14 @@ def deploy(self, skip_login=False, *args, **kwargs) -> DeploymentResult:
5757
click.secho("Could not log in to the registry. Please check your configured credentials.", fg="red")
5858
return DeploymentResult(False)
5959

60-
build_result = self.challenge.image.build()
61-
if not build_result:
62-
click.secho("Could not build the image. Please check docker output above.", fg="red")
63-
return DeploymentResult(False)
60+
if self.challenge.image.built:
61+
if not self.challenge.image.pull():
62+
click.secho("Could not pull the image. Please check docker output above.", fg="red")
63+
return DeploymentResult(False)
64+
else:
65+
if not self.challenge.image.build():
66+
click.secho("Could not build the image. Please check docker output above.", fg="red")
67+
return DeploymentResult(False)
6468

6569
push_result = self.challenge.image.push(location)
6670
if not push_result:
@@ -89,7 +93,9 @@ def _registry_login(self, username: str, password: str, registry: str):
8993
]
9094

9195
try:
92-
log.debug(f"call({docker_login_command}, stderr=subprocess.PIPE, input=password)")
96+
log.debug(
97+
f"call({docker_login_command}, input=password, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)"
98+
)
9399
subprocess.run(
94100
docker_login_command, input=password.encode(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
95101
)

ctfcli/core/deployment/ssh.py

+15-9
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,23 @@ def deploy(self, *args, **kwargs) -> DeploymentResult:
1919
)
2020
return DeploymentResult(False)
2121

22-
build_result = self.challenge.image.build()
23-
if not build_result:
24-
click.secho("Could not build the image. Please check docker output above.", fg="red")
25-
return DeploymentResult(False)
22+
if self.challenge.image.built:
23+
if not self.challenge.image.pull():
24+
click.secho("Could not pull the image. Please check docker output above.", fg="red")
25+
return DeploymentResult(False)
26+
else:
27+
if not self.challenge.image.build():
28+
click.secho("Could not build the image. Please check docker output above.", fg="red")
29+
return DeploymentResult(False)
2630

2731
image_name = self.challenge.image.name
28-
export_result = self.challenge.image.export()
29-
if not export_result:
32+
image_basename = self.challenge.image.basename
33+
image_export = self.challenge.image.export()
34+
if not image_export:
3035
click.secho("Could not export the image. Please check docker output above.", fg="red")
3136
return DeploymentResult(False)
3237

33-
image_export_path = Path(export_result)
38+
image_export_path = Path(image_export)
3439
host_url = urlparse(self.host)
3540
target_path = host_url.path or "/tmp"
3641
target_file = f"{target_path}/{image_export_path.name}"
@@ -54,14 +59,15 @@ def deploy(self, *args, **kwargs) -> DeploymentResult:
5459
[
5560
"ssh",
5661
host_url.netloc,
57-
f"docker stop {image_name} 2>/dev/null; docker rm {image_name} 2>/dev/null",
62+
f"docker stop {image_basename} 2>/dev/null; docker rm {image_basename} 2>/dev/null",
5863
]
5964
)
6065
subprocess.run(
6166
[
6267
"ssh",
6368
host_url.netloc,
64-
f"docker run -d -p{exposed_port}:{exposed_port} --name {image_name} --restart always {image_name}",
69+
f"docker run -d -p{exposed_port}:{exposed_port} --name {image_basename} "
70+
f"--restart always {image_name}",
6571
]
6672
)
6773

ctfcli/core/image.py

+22-4
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,21 @@
77

88

99
class Image:
10-
def __init__(self, name: str, build_path: Union[str, PathLike]):
10+
def __init__(self, name: str, build_path: Optional[Union[str, PathLike]] = None):
11+
# name can be either a new name to assign or an existing image name
1112
self.name = name
12-
self.build_path = Path(build_path)
13-
self.built = False
13+
14+
# if the image is a remote image (eg. ghcr.io/.../...), extract the basename
15+
self.basename = name
16+
if "/" in self.name or ":" in self.name:
17+
self.basename = self.name.split(":")[0].split("/")[-1]
18+
19+
self.built = True
20+
21+
# if the image provides a build path, assume it is not built yet
22+
if build_path:
23+
self.build_path = Path(build_path)
24+
self.built = False
1425

1526
def build(self) -> Optional[str]:
1627
docker_build = subprocess.call(["docker", "build", "-t", self.name, "."], cwd=self.build_path.absolute())
@@ -20,6 +31,13 @@ def build(self) -> Optional[str]:
2031
self.built = True
2132
return self.name
2233

34+
def pull(self) -> Optional[str]:
35+
docker_pull = subprocess.call(["docker", "pull", self.name])
36+
if docker_pull != 0:
37+
return
38+
39+
return self.name
40+
2341
def push(self, location: str) -> Optional[str]:
2442
if not self.built:
2543
self.build()
@@ -36,7 +54,7 @@ def export(self) -> Optional[str]:
3654
if not self.built:
3755
self.build()
3856

39-
image_tar = tempfile.NamedTemporaryFile(delete=False, suffix=f"_{self.name}.docker.tar")
57+
image_tar = tempfile.NamedTemporaryFile(delete=False, suffix=f"_{self.basename}.docker.tar")
4058
docker_save = subprocess.call(["docker", "save", "--output", image_tar.name, self.name])
4159

4260
if docker_save != 0:

tests/core/deployment/test_cloud_deployment.py

+10
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ def test_fails_deployment_if_image_build_failed(
8383

8484
mock_image: MagicMock = mock_image_constructor.return_value
8585
mock_image.name = "test-challenge"
86+
mock_image.basename = "test-challenge"
87+
mock_image.built = False
8688
mock_image.build.return_value = None
8789

8890
mock_api: MagicMock = mock_api_constructor.return_value
@@ -134,6 +136,7 @@ def test_fails_deployment_if_image_push_failed(
134136

135137
mock_image: MagicMock = mock_image_constructor.return_value
136138
mock_image.name = "test-challenge"
139+
mock_image.basename = "test-challenge"
137140
mock_image.build.return_value = "test-challenge"
138141
mock_image.push.return_value = None
139142

@@ -201,6 +204,7 @@ def test_fails_deployment_if_registry_login_unsuccessful(
201204

202205
mock_image: MagicMock = mock_image_constructor.return_value
203206
mock_image.name = "test-challenge"
207+
mock_image.basename = "test-challenge"
204208
mock_image.build.return_value = "test-challenge"
205209

206210
mock_api: MagicMock = mock_api_constructor.return_value
@@ -282,6 +286,7 @@ def test_fails_deployment_if_instance_url_is_not_ctfd_assigned(
282286

283287
mock_image: MagicMock = mock_image_constructor.return_value
284288
mock_image.name = "test-challenge"
289+
mock_image.basename = "test-challenge"
285290
mock_image.build.return_value = "test-challenge"
286291

287292
mock_api: MagicMock = mock_api_constructor.return_value
@@ -365,6 +370,7 @@ def test_allows_skipping_registry_login(
365370

366371
mock_image: MagicMock = mock_image_constructor.return_value
367372
mock_image.name = "test-challenge"
373+
mock_image.basename = "test-challenge"
368374
mock_image.build.return_value = "test-challenge"
369375
mock_image.push.return_value = "registry.ctfd.io/example-project/test-challenge"
370376

@@ -495,6 +501,7 @@ def test_deploys_challenge_with_existing_image_service(
495501

496502
mock_image: MagicMock = mock_image_constructor.return_value
497503
mock_image.name = "test-challenge"
504+
mock_image.basename = "test-challenge"
498505
mock_image.build.return_value = "test-challenge"
499506
mock_image.push.return_value = "registry.ctfd.io/example-project/test-challenge"
500507

@@ -637,6 +644,7 @@ def test_deploys_challenge_with_new_image_service(
637644

638645
mock_image: MagicMock = mock_image_constructor.return_value
639646
mock_image.name = "test-challenge"
647+
mock_image.basename = "test-challenge"
640648
mock_image.build.return_value = "test-challenge"
641649
mock_image.push.return_value = "registry.ctfd.io/example-project/test-challenge"
642650

@@ -830,6 +838,7 @@ def test_fails_deployment_after_timeout(
830838

831839
mock_image = mock_image_constructor.return_value
832840
mock_image.name = "test-challenge"
841+
mock_image.basename = "test-challenge"
833842
mock_image.build.return_value = "test-challenge"
834843
mock_image.push.return_value = "registry.ctfd.io/example-project/test-challenge"
835844

@@ -1019,6 +1028,7 @@ def test_exposes_tcp_port(
10191028

10201029
mock_image = mock_image_constructor.return_value
10211030
mock_image.name = "test-challenge"
1031+
mock_image.basename = "test-challenge"
10221032
mock_image.build.return_value = "test-challenge"
10231033
mock_image.push.return_value = "registry.ctfd.io/example-project/test-challenge"
10241034

tests/core/deployment/test_registry_deployment.py

+9
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def test_builds_and_pushes_image(
3030

3131
mock_image: MagicMock = mock_image_constructor.return_value
3232
mock_image.name = "test-challenge"
33+
mock_image.basename = "test-challenge"
3334
mock_image.build.return_value = "test-challenge"
3435
mock_image.push.return_value = "registry.example.com/example-project/test-challenge"
3536

@@ -79,6 +80,7 @@ def test_fails_deployment_if_no_registry_config(
7980

8081
mock_image: MagicMock = mock_image_constructor.return_value
8182
mock_image.name = "test-challenge"
83+
mock_image.basename = "test-challenge"
8284
mock_image.build.return_value = "test-challenge"
8385

8486
handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project")
@@ -104,6 +106,7 @@ def test_fails_if_no_credentials(
104106

105107
mock_image: MagicMock = mock_image_constructor.return_value
106108
mock_image.name = "test-challenge"
109+
mock_image.basename = "test-challenge"
107110
mock_image.build.return_value = "test-challenge"
108111

109112
mock_config_constructor.return_value = {"registry": {"username": "test"}}
@@ -133,6 +136,7 @@ def test_fails_if_registry_credentials_invalid(
133136

134137
mock_image: MagicMock = mock_image_constructor.return_value
135138
mock_image.name = "test-challenge"
139+
mock_image.basename = "test-challenge"
136140
mock_image.build.return_value = "test-challenge"
137141

138142
mock_config_constructor.return_value = {"registry": {"username": "test", "password": "test"}}
@@ -164,6 +168,8 @@ def test_fails_deployment_if_image_build_failed(
164168

165169
mock_image: MagicMock = mock_image_constructor.return_value
166170
mock_image.name = "test-challenge"
171+
mock_image.basename = "test-challenge"
172+
mock_image.built = False
167173
mock_image.build.return_value = None
168174

169175
mock_config_constructor.return_value = {"registry": {"username": "test", "password": "test"}}
@@ -192,6 +198,7 @@ def test_fails_deployment_if_image_push_failed(
192198

193199
mock_image: MagicMock = mock_image_constructor.return_value
194200
mock_image.name = "test-challenge"
201+
mock_image.basename = "test-challenge"
195202
mock_image.build.return_value = "test-challenge"
196203
mock_image.push.return_value = None
197204

@@ -224,6 +231,7 @@ def test_allows_skipping_login(
224231

225232
mock_image: MagicMock = mock_image_constructor.return_value
226233
mock_image.name = "test-challenge"
234+
mock_image.basename = "test-challenge"
227235
mock_image.build.return_value = "test-challenge"
228236
mock_image.push.return_value = "registry.example.com/example-project/test-challenge"
229237

@@ -257,6 +265,7 @@ def test_warns_about_logging_in_with_skip_login(
257265

258266
mock_image: MagicMock = mock_image_constructor.return_value
259267
mock_image.name = "test-challenge"
268+
mock_image.basename = "test-challenge"
260269
mock_image.build.return_value = "test-challenge"
261270
mock_image.push.return_value = None
262271

0 commit comments

Comments
 (0)