diff --git a/ctfcli/core/deployment/registry.py b/ctfcli/core/deployment/registry.py index 0db741f..98fa9e1 100644 --- a/ctfcli/core/deployment/registry.py +++ b/ctfcli/core/deployment/registry.py @@ -25,6 +25,10 @@ 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..28d58fe 100644 --- a/ctfcli/core/deployment/ssh.py +++ b/ctfcli/core/deployment/ssh.py @@ -19,6 +19,47 @@ 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 = 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( + ["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 '{remote_path}' && 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..d508233 100644 --- a/ctfcli/core/exceptions.py +++ b/ctfcli/core/exceptions.py @@ -37,6 +37,14 @@ 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): self.issues = issues if issues else {} diff --git a/ctfcli/core/image.py b/ctfcli/core/image.py index c97ead8..64e78d2 100644 --- a/ctfcli/core/image.py +++ b/ctfcli/core/image.py @@ -4,6 +4,8 @@ from os import PathLike from pathlib import Path +from ctfcli.core.exceptions import InvalidComposeOperation + class Image: def __init__(self, name: str, build_path: str | PathLike | None = None): @@ -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