From 33472dd7a7f46b7609f675fbc5f775fc09cf0939 Mon Sep 17 00:00:00 2001 From: Chandler Newby Date: Tue, 8 Apr 2025 19:26:14 -0600 Subject: [PATCH] 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 | 4 +++- 6 files changed, 71 insertions(+), 3 deletions(-) diff --git a/ctfcli/core/challenge.py b/ctfcli/core/challenge.py index 553cc91..72f9212 100644 --- a/ctfcli/core/challenge.py +++ b/ctfcli/core/challenge.py @@ -193,6 +193,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://", "") @@ -732,8 +736,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 bb5e4a4..f471a1d 100644 --- a/ctfcli/core/exceptions.py +++ b/ctfcli/core/exceptions.py @@ -22,6 +22,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 903b4c3..ce329be 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 @@ -122,4 +124,4 @@ state: hidden # Specifies what version of the challenge specification was used. # Subject to change until ctfcli v1.0.0 -version: "0.1" \ No newline at end of file +version: "0.1"