From 8f4d1558943a58db343c4910bb2806ccd2ab1d95 Mon Sep 17 00:00:00 2001 From: Chandler Newby Date: Tue, 8 Apr 2025 19:26:14 -0600 Subject: [PATCH 1/6] Support docker compose ssh deployment --- ctfcli/core/deployment/registry.py | 6 ++++++ ctfcli/core/deployment/ssh.py | 32 ++++++++++++++++++++++++++++++ ctfcli/core/exceptions.py | 6 ++++++ ctfcli/core/image.py | 19 ++++++++++++++++++ ctfcli/core/lint.py | 4 ++-- ctfcli/core/properties/image.py | 4 ++++ ctfcli/spec/challenge-example.yml | 2 ++ 7 files changed, 71 insertions(+), 2 deletions(-) diff --git a/ctfcli/core/deployment/registry.py b/ctfcli/core/deployment/registry.py index 0db741f..507db42 100644 --- a/ctfcli/core/deployment/registry.py +++ b/ctfcli/core/deployment/registry.py @@ -25,6 +25,12 @@ def deploy(self, skip_login=False, *args, **kwargs) -> DeploymentResult: ) return DeploymentResult(False) + if self.challenge.image.compose: + click.secho( + "Cannot use registry deployer with __compose__ stacks", fg="red" + ) + return DeploymentResult(False) + # resolve a location for the image push # e.g. registry.example.com/test-project/challenge-image-name # challenge image name is appended to the host provided for the deployment diff --git a/ctfcli/core/deployment/ssh.py b/ctfcli/core/deployment/ssh.py index a39b5a1..7fc09dd 100644 --- a/ctfcli/core/deployment/ssh.py +++ b/ctfcli/core/deployment/ssh.py @@ -19,6 +19,38 @@ def deploy(self, *args, **kwargs) -> DeploymentResult: ) return DeploymentResult(False) + if self.challenge.image.compose: + return self._deploy_compose_stack(*args, **kwargs) + + return self._deploy_single_image(*args, **kwargs) + + def _deploy_compose_stack(self, *args, **kwargs) -> DeploymentResult: + host_url = urlparse(self.host) + target_path = host_url.path or "~/" + try: + subprocess.run(["ssh", host_url.netloc, f"mkdir -p {target_path}/"], check=True) + subprocess.run( + ["rsync", "-a", "--delete", self.challenge.challenge_directory, f"{host_url.netloc}:{target_path}"], + check=True, + ) + subprocess.run( + [ + "ssh", + host_url.netloc, + f"cd {target_path}/{self.challenge.challenge_directory.name} && " + "docker compose up -d --build --remove-orphans -y", + ], + check=True, + ) + + except subprocess.CalledProcessError as e: + click.secho("Failed to deploy compose stack!", fg="red") + click.secho(str(e), fg="red") + return DeploymentResult(False) + + return DeploymentResult(True) + + def _deploy_single_image(self, *args, **kwargs) -> DeploymentResult: if self.challenge.image.built: if not self.challenge.image.pull(): click.secho("Could not pull the image. Please check docker output above.", fg="red") diff --git a/ctfcli/core/exceptions.py b/ctfcli/core/exceptions.py index 1ac3a62..758cf1c 100644 --- a/ctfcli/core/exceptions.py +++ b/ctfcli/core/exceptions.py @@ -36,6 +36,12 @@ class InvalidChallengeFile(ChallengeException): class RemoteChallengeNotFound(ChallengeException): pass +class ImageException(ChallengeException): + pass + +class InvalidComposeOperation(ImageException): + pass + class LintException(Exception): def __init__(self, *args, issues: dict[str, list[str]] | None = None): diff --git a/ctfcli/core/image.py b/ctfcli/core/image.py index c97ead8..8849da6 100644 --- a/ctfcli/core/image.py +++ b/ctfcli/core/image.py @@ -3,6 +3,8 @@ import tempfile from os import PathLike from pathlib import Path +from typing import Optional, Union +from ctfcli.core.exceptions import InvalidComposeOperation class Image: @@ -15,6 +17,11 @@ def __init__(self, name: str, build_path: str | PathLike | None = None): if "/" in self.name or ":" in self.name: self.basename = self.name.split(":")[0].split("/")[-1] + if self.name == "__compose__": + self.compose = True + else: + self.compose = False + self.built = True # if the image provides a build path, assume it is not built yet @@ -23,6 +30,9 @@ def __init__(self, name: str, build_path: str | PathLike | None = None): self.built = False def build(self) -> str | None: + if self.compose: + raise InvalidComposeOperation("Local build not supported for docker compose challenges") + docker_build = subprocess.call( ["docker", "build", "--load", "-t", self.name, "."], cwd=self.build_path.absolute() ) @@ -33,6 +43,9 @@ def build(self) -> str | None: return self.name def pull(self) -> str | None: + if self.compose: + raise InvalidComposeOperation("Local pull not supported for docker compose challenges") + docker_pull = subprocess.call(["docker", "pull", self.name]) if docker_pull != 0: return None @@ -40,6 +53,9 @@ def pull(self) -> str | None: return self.name def push(self, location: str) -> str | None: + if self.compose: + raise InvalidComposeOperation("Local push not supported for docker compose challenges") + if not self.built: self.build() @@ -52,6 +68,9 @@ def push(self, location: str) -> str | None: return location def export(self) -> str | None: + if self.compose: + raise InvalidComposeOperation("Local export not supported for docker compose challenges") + if not self.built: self.build() diff --git a/ctfcli/core/lint.py b/ctfcli/core/lint.py index 7f3dd69..17149ba 100644 --- a/ctfcli/core/lint.py +++ b/ctfcli/core/lint.py @@ -31,8 +31,8 @@ def lint_challenge(challenge, skip_hadolint: bool = False, flag_format: str = "f prop.lint(challenge, issues) # Check that the image field and Dockerfile match - if (challenge.challenge_directory / "Dockerfile").is_file() and challenge.get("image", "") != ".": - issues["dockerfile"].append("Dockerfile exists but image field does not point to it") + if (challenge.challenge_directory / "Dockerfile").is_file() and challenge.get("image", "") not in [".", "__compose__"]: + issues["dockerfile"].append("Dockerfile exists but image field does not point to it or compose") # Check that Dockerfile exists and is EXPOSE'ing a port if challenge.get("image") == ".": diff --git a/ctfcli/core/properties/image.py b/ctfcli/core/properties/image.py index 584baeb..5fca305 100644 --- a/ctfcli/core/properties/image.py +++ b/ctfcli/core/properties/image.py @@ -34,6 +34,10 @@ def resolve(self, ctx: PropertyContext) -> Image | None: if not challenge_image: return None + # Check if challenge_image is explicitly marked as __compose__ + if challenge_image == "__compose__": + return Image(challenge_image) + # Check if challenge_image is explicitly marked with registry:// prefix if challenge_image.startswith("registry://"): challenge_image = challenge_image.replace("registry://", "") diff --git a/ctfcli/spec/challenge-example.yml b/ctfcli/spec/challenge-example.yml index 9b8f46d..c611181 100644 --- a/ctfcli/spec/challenge-example.yml +++ b/ctfcli/spec/challenge-example.yml @@ -30,6 +30,8 @@ type: standard # Settings used for Dockerfile deployment # If not used, remove or set to null # If you have a Dockerfile set to . +# If you have a docker-compose.yaml file, set to __compose__. Note that this will send the entire challenge directory to the remote server and build it there. +# Only compatible with ssh, not registry. # If you have an imaged hosted on Docker set to the image url (e.g. python/3.8:latest, registry.gitlab.com/python/3.8:latest) # Follow Docker best practices and assign a tag image: null From f9a5912e8632dd65f8ee5e366a42062554a9a59c Mon Sep 17 00:00:00 2001 From: Chandler Newby Date: Thu, 11 Sep 2025 01:05:56 -0600 Subject: [PATCH 2/6] Fix a few issues with docker compose deploy --- ctfcli/core/deployment/ssh.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/ctfcli/core/deployment/ssh.py b/ctfcli/core/deployment/ssh.py index 7fc09dd..f385ed7 100644 --- a/ctfcli/core/deployment/ssh.py +++ b/ctfcli/core/deployment/ssh.py @@ -26,18 +26,28 @@ def deploy(self, *args, **kwargs) -> DeploymentResult: def _deploy_compose_stack(self, *args, **kwargs) -> DeploymentResult: host_url = urlparse(self.host) - target_path = host_url.path or "~/" + target_path = str(host_url.path) + if target_path == '/': # Don't put challenges in the root of the filesystem. + target_path = '' + elif target_path == '//': # If you really want to, add a second slash as part of your path: ssh://1.1.1.1// + target_path = '/' + elif target_path.startswith('/~/'): # Support relative paths by starting your path with /~/ + target_path = target_path.removeprefix('/~/') try: - subprocess.run(["ssh", host_url.netloc, f"mkdir -p {target_path}/"], check=True) + subprocess.run(["ssh", host_url.netloc, f"mkdir -p '{target_path}/'"], check=True) subprocess.run( ["rsync", "-a", "--delete", self.challenge.challenge_directory, f"{host_url.netloc}:{target_path}"], check=True, ) + if not target_path: + remote_path = f"{self.challenge.challenge_directory.name}" + else: + remote_path = f"{target_path}/{self.challenge.challenge_directory.name}" subprocess.run( [ "ssh", host_url.netloc, - f"cd {target_path}/{self.challenge.challenge_directory.name} && " + f"cd {remote_path} && " "docker compose up -d --build --remove-orphans -y", ], check=True, From aa34c4fbea05da6bc7f6bb0ad875055b7cce3d7c Mon Sep 17 00:00:00 2001 From: Chandler Newby Date: Fri, 10 Oct 2025 00:31:43 -0600 Subject: [PATCH 3/6] Fix linting errors and rebase --- ctfcli/core/deployment/registry.py | 4 +--- ctfcli/core/deployment/ssh.py | 15 +++++++-------- ctfcli/core/exceptions.py | 2 ++ 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/ctfcli/core/deployment/registry.py b/ctfcli/core/deployment/registry.py index 507db42..98fa9e1 100644 --- a/ctfcli/core/deployment/registry.py +++ b/ctfcli/core/deployment/registry.py @@ -26,9 +26,7 @@ def deploy(self, skip_login=False, *args, **kwargs) -> DeploymentResult: return DeploymentResult(False) if self.challenge.image.compose: - click.secho( - "Cannot use registry deployer with __compose__ stacks", fg="red" - ) + click.secho("Cannot use registry deployer with __compose__ stacks", fg="red") return DeploymentResult(False) # resolve a location for the image push diff --git a/ctfcli/core/deployment/ssh.py b/ctfcli/core/deployment/ssh.py index f385ed7..7b3624c 100644 --- a/ctfcli/core/deployment/ssh.py +++ b/ctfcli/core/deployment/ssh.py @@ -27,12 +27,12 @@ def deploy(self, *args, **kwargs) -> DeploymentResult: def _deploy_compose_stack(self, *args, **kwargs) -> DeploymentResult: host_url = urlparse(self.host) target_path = str(host_url.path) - if target_path == '/': # Don't put challenges in the root of the filesystem. - target_path = '' - elif target_path == '//': # If you really want to, add a second slash as part of your path: ssh://1.1.1.1// - target_path = '/' - elif target_path.startswith('/~/'): # Support relative paths by starting your path with /~/ - target_path = target_path.removeprefix('/~/') + if target_path == "/": # Don't put challenges in the root of the filesystem. + target_path = "" + elif target_path == "//": # If you really want to, add a second slash as part of your path: ssh://1.1.1.1// + target_path = "/" + elif target_path.startswith("/~/"): # Support relative paths by starting your path with /~/ + target_path = target_path.removeprefix("/~/") try: subprocess.run(["ssh", host_url.netloc, f"mkdir -p '{target_path}/'"], check=True) subprocess.run( @@ -47,8 +47,7 @@ def _deploy_compose_stack(self, *args, **kwargs) -> DeploymentResult: [ "ssh", host_url.netloc, - f"cd {remote_path} && " - "docker compose up -d --build --remove-orphans -y", + f"cd {remote_path} && " "docker compose up -d --build --remove-orphans -y", ], check=True, ) diff --git a/ctfcli/core/exceptions.py b/ctfcli/core/exceptions.py index 758cf1c..d508233 100644 --- a/ctfcli/core/exceptions.py +++ b/ctfcli/core/exceptions.py @@ -36,9 +36,11 @@ class InvalidChallengeFile(ChallengeException): class RemoteChallengeNotFound(ChallengeException): pass + class ImageException(ChallengeException): pass + class InvalidComposeOperation(ImageException): pass From a98bf24e5e989c2a86ceed20c26f5204d15c3db6 Mon Sep 17 00:00:00 2001 From: Chandler Newby Date: Fri, 10 Oct 2025 00:38:03 -0600 Subject: [PATCH 4/6] More linting fixes --- ctfcli/core/image.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ctfcli/core/image.py b/ctfcli/core/image.py index 8849da6..70ff852 100644 --- a/ctfcli/core/image.py +++ b/ctfcli/core/image.py @@ -4,6 +4,7 @@ from os import PathLike from pathlib import Path from typing import Optional, Union + from ctfcli.core.exceptions import InvalidComposeOperation From 5320b812fe3f5c07f0ffcef0be2451fc4585e2ad Mon Sep 17 00:00:00 2001 From: Chandler Newby Date: Tue, 11 Nov 2025 15:39:49 -0700 Subject: [PATCH 5/6] Support spaces in path in docker compose deployment --- ctfcli/core/deployment/ssh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ctfcli/core/deployment/ssh.py b/ctfcli/core/deployment/ssh.py index 7b3624c..28d58fe 100644 --- a/ctfcli/core/deployment/ssh.py +++ b/ctfcli/core/deployment/ssh.py @@ -47,7 +47,7 @@ def _deploy_compose_stack(self, *args, **kwargs) -> DeploymentResult: [ "ssh", host_url.netloc, - f"cd {remote_path} && " "docker compose up -d --build --remove-orphans -y", + f"cd '{remote_path}' && docker compose up -d --build --remove-orphans -y", ], check=True, ) From 710b48170da176d2df3106ad9a7ed0250cd0c562 Mon Sep 17 00:00:00 2001 From: Chandler Newby Date: Thu, 17 Sep 2026 13:33:27 -0600 Subject: [PATCH 6/6] Remove unneeded import after rebasing --- ctfcli/core/image.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ctfcli/core/image.py b/ctfcli/core/image.py index 70ff852..64e78d2 100644 --- a/ctfcli/core/image.py +++ b/ctfcli/core/image.py @@ -3,7 +3,6 @@ import tempfile from os import PathLike from pathlib import Path -from typing import Optional, Union from ctfcli.core.exceptions import InvalidComposeOperation