From b57d40b9ef2a79e4adbf0c68b5314f4f567f21e3 Mon Sep 17 00:00:00 2001 From: Chandler Newby Date: Tue, 8 Apr 2025 19:26:14 -0600 Subject: [PATCH 1/2] Support docker compose ssh deployment --- ctfcli/core/challenge.py | 8 ++++++-- ctfcli/core/deployment/registry.py | 6 ++++++ ctfcli/core/deployment/ssh.py | 32 ++++++++++++++++++++++++++++++ ctfcli/core/exceptions.py | 6 ++++++ ctfcli/core/image.py | 18 +++++++++++++++++ ctfcli/spec/challenge-example.yml | 2 ++ 6 files changed, 70 insertions(+), 2 deletions(-) diff --git a/ctfcli/core/challenge.py b/ctfcli/core/challenge.py index 969426b..91c0dff 100644 --- a/ctfcli/core/challenge.py +++ b/ctfcli/core/challenge.py @@ -198,6 +198,10 @@ def _process_challenge_image(self, challenge_image: Optional[str]) -> Optional[I 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://", "") @@ -881,8 +885,8 @@ def lint(self, skip_hadolint=False, flag_format="flag{") -> bool: issues["fields"].append(f"challenge.yml is missing required field: {field}") # Check that the image field and Dockerfile match - if (self.challenge_directory / "Dockerfile").is_file() and challenge.get("image", "") != ".": - issues["dockerfile"].append("Dockerfile exists but image field does not point to it") + if (self.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/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 1530b5f..bd660e8 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 5c49d2d..4c23467 100644 --- a/ctfcli/core/exceptions.py +++ b/ctfcli/core/exceptions.py @@ -38,6 +38,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): diff --git a/ctfcli/core/image.py b/ctfcli/core/image.py index 025187f..b2bf096 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 class Image: @@ -16,6 +17,11 @@ def __init__(self, name: str, build_path: Optional[Union[str, PathLike]] = 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 @@ -24,6 +30,9 @@ def __init__(self, name: str, build_path: Optional[Union[str, PathLike]] = None) self.built = False def build(self) -> Optional[str]: + 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() ) @@ -34,6 +43,9 @@ def build(self) -> Optional[str]: return self.name def pull(self) -> Optional[str]: + 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 @@ -41,6 +53,9 @@ def pull(self) -> Optional[str]: return self.name def push(self, location: str) -> Optional[str]: + if self.compose: + raise InvalidComposeOperation("Local push not supported for docker compose challenges") + if not self.built: self.build() @@ -53,6 +68,9 @@ def push(self, location: str) -> Optional[str]: return location def export(self) -> Optional[str]: + if self.compose: + raise InvalidComposeOperation("Local export not supported for docker compose challenges") + if not self.built: self.build() diff --git a/ctfcli/spec/challenge-example.yml b/ctfcli/spec/challenge-example.yml index ecee287..9405d01 100644 --- a/ctfcli/spec/challenge-example.yml +++ b/ctfcli/spec/challenge-example.yml @@ -22,6 +22,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 dd4dc84b7923128b86e400c816ce74f03c8fa473 Mon Sep 17 00:00:00 2001 From: Chandler Newby Date: Thu, 11 Sep 2025 01:05:56 -0600 Subject: [PATCH 2/2] 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 bd660e8..7d2712b 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,