Skip to content

Support docker compose ssh deployment #178

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions ctfcli/core/challenge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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://", "")
Expand Down Expand Up @@ -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") == ".":
Expand Down
6 changes: 6 additions & 0 deletions ctfcli/core/deployment/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions ctfcli/core/deployment/ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions ctfcli/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
18 changes: 18 additions & 0 deletions ctfcli/core/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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()
)
Expand All @@ -34,13 +43,19 @@ 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

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()

Expand All @@ -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()

Expand Down
4 changes: 3 additions & 1 deletion ctfcli/spec/challenge-example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
version: "0.1"
Loading