Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
"editor.defaultFormatter": "charliermarsh.ruff"
},
"python.testing.pytestArgs": [
"dreadnode_cli"
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}
}
32 changes: 32 additions & 0 deletions dreadnode/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@

from dreadnode.api.models import (
AccessRefreshTokenResponse,
ContainerRegistryCredentials,
DeviceCodeResponse,
GithubTokenResponse,
MetricAggregationType,
Project,
RawRun,
RawTask,
RegistryImageDetails,
Run,
RunSummary,
StatusFilter,
Expand Down Expand Up @@ -539,3 +541,33 @@ def get_user_data_credentials(self) -> UserDataCredentials:
"""
response = self._request("GET", "/user-data/credentials")
return UserDataCredentials(**response.json())

# Container registry access

def get_container_registry_credentials(self) -> ContainerRegistryCredentials:
"""
Retrieves container registry credentials for Docker image access.

Returns:
The container registry credentials object.
"""
response = self.request("POST", "/platform/registry-token")
return ContainerRegistryCredentials(**response.json())

def get_platform_releases(
self, arch: str, tag: str, services: list[str], cli_version: str
) -> RegistryImageDetails:
"""
Resolves the platform releases for the current project.

Returns:
The resolved platform releases as a ResolveReleasesResponse object.
"""
payload = {
"arch": arch,
"tag": tag,
"services": services,
"cli_version": cli_version,
}
response = self.request("POST", "/platform/get-releases", json_data=payload)
return RegistryImageDetails(**response.json())
23 changes: 23 additions & 0 deletions dreadnode/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,29 @@ class UserDataCredentials(BaseModel):
endpoint: str | None


class ContainerRegistryCredentials(BaseModel):
registry: str
username: str
password: str
expires_at: datetime


class PlatformImage(BaseModel):
service: str
uri: str
digest: str
version: str

@property
def full_uri(self) -> str:
return f"{self.uri}@{self.digest}"


class RegistryImageDetails(BaseModel):
version: str
images: list[PlatformImage]


# Auth


Expand Down
4 changes: 3 additions & 1 deletion dreadnode/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
download_and_unzip_archive,
validate_server_for_clone,
)
from dreadnode.cli.platform import cli as platform_cli
from dreadnode.cli.profile import cli as profile_cli
from dreadnode.config import ServerConfig, UserConfig
from dreadnode.constants import DEBUG, PLATFORM_BASE_URL
Expand All @@ -26,8 +27,9 @@

cli["--help"].group = "Meta"

cli.command(profile_cli)
cli.command(agent_cli)
cli.command(platform_cli)
cli.command(profile_cli)


@cli.meta.default
Expand Down
3 changes: 3 additions & 0 deletions dreadnode/cli/platform/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from dreadnode.cli.platform.cli import cli

__all__ = ["cli"]
45 changes: 45 additions & 0 deletions dreadnode/cli/platform/check_for_updates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import rich

from dreadnode.cli.api import create_api_client
from dreadnode.cli.platform.constants import SERVICES
from dreadnode.cli.platform.utils import get_local_arch, get_local_cache_dir, get_local_version


def check_for_updates() -> None:
import importlib.metadata # noqa: PLC0415

local_cache_dir = get_local_cache_dir()
rich.print(f"Checking local cache directory: {local_cache_dir}")

if not local_cache_dir.exists():
rich.print(
"Local cache directory does not exist. Please run \n[dim]$[/dim] [bold green]dreadnode platform init[/bold green]"
)
return

arch = get_local_arch()
api_client = create_api_client()
registry_image_details = api_client.get_platform_releases(
arch=arch,
tag="latest",
services=SERVICES,
cli_version=importlib.metadata.version("dreadnode"),
)

local_image_details = get_local_version()

for image_detail in local_image_details.images:
for remote_image_detail in registry_image_details.images:
if image_detail.service == remote_image_detail.service:
if image_detail.version != remote_image_detail.version:
rich.print(
f"[yellow]Update available for {image_detail.service}: "
f"{image_detail.version} -> {remote_image_detail.version}[/yellow]"
)
else:
rich.print(
f"[green]{image_detail.service} is up to date: {image_detail.version}[/green]"
)
rich.print(
"[blue]You can update with:[/blue]\n[dim]$[/dim] [bold green]dreadnode platform update[/bold green]"
)
75 changes: 75 additions & 0 deletions dreadnode/cli/platform/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import cyclopts

from dreadnode.cli.platform.check_for_updates import check_for_updates as check_for_updates_
from dreadnode.cli.platform.configure import configure_platform
from dreadnode.cli.platform.docker.download import download as download_platform
from dreadnode.cli.platform.docker.login import docker_login
from dreadnode.cli.platform.docker.start import start as start_platform
from dreadnode.cli.platform.docker.start import stop as stop_platform
from dreadnode.cli.platform.init import init as init_platform
from dreadnode.cli.platform.init import initialized as platform_initilized

cli = cyclopts.App("platform", help="Run and manage the platform.", help_flags=[])


@cli.command()
def init(tag: str = "latest", arch: str | None = None) -> None:
"""
Initialize the platform.
"""
init_platform(tag=tag, arch=arch)


@cli.command()
def download(tag: str = "latest", arch: str | None = None) -> None:
"""
Download the platform files.
"""
docker_login()

if not platform_initilized() or tag != "latest" or arch:
init_platform(tag=tag, arch=arch)

download_platform()


@cli.command()
def configure() -> None:
"""
Configure the platform.
"""
configure_platform()


@cli.command()
def start() -> None:
"""
Start the platform services.
"""
start_platform()


@cli.command()
def stop() -> None:
"""
Stop the platform services.
"""
stop_platform()


@cli.command()
def check_for_updates() -> None:
"""
Check for platform updates.
"""
check_for_updates_()


@cli.command()
def update() -> None:
"""
Update the platform.
"""
stop_platform()
download_platform()
start_platform()
9 changes: 9 additions & 0 deletions dreadnode/cli/platform/configure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import rich

from dreadnode.cli.platform.utils import get_local_cache_dir


def configure_platform() -> None:
rich.print(f"Configure the API by modifying {get_local_cache_dir()}/.api.env")
rich.print(f"Configure the UI by modifying {get_local_cache_dir()}/.ui.env")
rich.print("See https://docs.dreadnode.io/platform/manage for more details.")
10 changes: 10 additions & 0 deletions dreadnode/cli/platform/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from pathlib import Path

API_SERVICE = "api"
UI_SERVICE = "ui"
SERVICES = [API_SERVICE, UI_SERVICE]

TEMPLATE_DIR = Path(__file__).parent / "templates"
DOCKER_COMPOSE_TEMPLATE = TEMPLATE_DIR / "docker-compose.yaml.j2"
API_ENV_TEMPLATE = TEMPLATE_DIR / ".api.env.j2"
UI_ENV_TEMPLATE = TEMPLATE_DIR / ".ui.env.j2"
77 changes: 77 additions & 0 deletions dreadnode/cli/platform/docker/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import subprocess
import sys

import rich

from dreadnode.cli.platform.utils import get_compose_file_path


def run_docker_compose_command(
args: list[str],
compose_file: str | None = None,
project_name: str | None = None,
timeout: int = 300,
command_name: str = "docker compose",
stdin_input: str | None = None,
) -> subprocess.CompletedProcess[str]:
"""
Execute a docker compose command with common error handling and configuration.

Args:
args: Additional arguments for the docker compose command
compose_file: Path to docker-compose file (optional)
project_name: Docker compose project name (optional)
timeout: Command timeout in seconds
command_name: Name of the command for error messages
stdin_input: Input to pass to stdin (for commands like docker login)

Returns:
CompletedProcess object with command results

Raises:
subprocess.CalledProcessError: If command fails
subprocess.TimeoutExpired: If command times out
FileNotFoundError: If docker/docker-compose not found
"""
cmd = ["docker", "compose"]

# Add compose file
compose_file = compose_file or get_compose_file_path()
cmd.extend(["-f", compose_file])

# Add project name if specified
if project_name:
cmd.extend(["-p", project_name])

# Add the specific command arguments
cmd.extend(args)

try:
# Remove capture_output=True to allow real-time streaming
# stdout and stderr will go directly to the terminal
result = subprocess.run( # noqa: S603
cmd,
check=True,
text=True,
timeout=timeout,
encoding="utf-8",
errors="replace",
input=stdin_input,
)

except subprocess.CalledProcessError as e:
rich.print(f"{command_name} failed with exit code {e.returncode}", file=sys.stderr)
raise

except subprocess.TimeoutExpired:
rich.print(f"{command_name} timed out after {timeout} seconds", file=sys.stderr)
raise

except FileNotFoundError:
rich.print(
"Docker or docker compose not found. Please ensure Docker is installed.",
file=sys.stderr,
)
raise

return result
Loading
Loading