Skip to content

Commit f3089e1

Browse files
committed
client wip
1 parent 72ea2fc commit f3089e1

File tree

8 files changed

+230
-116
lines changed

8 files changed

+230
-116
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ dependencies = [
7272
"langchain-anthropic>=0.3.7",
7373
"lox>=0.12.0",
7474
"httpx>=0.28.1",
75+
"docker>=6.1.3",
7576
]
7677

7778
license = { text = "Apache-2.0" }
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import docker
2+
3+
CODEGEN_RUNNER_IMAGE = "codegen-runner"
4+
5+
6+
class DockerSession:
7+
_client: docker.DockerClient
8+
host: str | None
9+
port: int | None
10+
name: str
11+
12+
def __init__(self, client: docker.DockerClient, name: str, port: int | None = None, host: str | None = None):
13+
self._client = client
14+
self.host = host
15+
self.port = port
16+
self.name = name
17+
18+
def is_running(self) -> bool:
19+
try:
20+
container = self._client.containers.get(self.name)
21+
return container.status == "running"
22+
except docker.errors.NotFound:
23+
return False
24+
25+
def start(self) -> bool:
26+
try:
27+
container = self._client.containers.get(self.name)
28+
container.start()
29+
return True
30+
except (docker.errors.NotFound, docker.errors.APIError):
31+
return False
32+
33+
def __str__(self) -> str:
34+
return f"DockerSession(name={self.name}, host={self.host or 'unknown'}, port={self.port or 'unknown'})"
35+
36+
37+
class DockerSessions:
38+
sessions: list[DockerSession]
39+
40+
def __init__(self, sessions: list[DockerSession]):
41+
self.sessions = sessions
42+
43+
@classmethod
44+
def load(cls) -> "DockerSessions":
45+
try:
46+
client = docker.from_env()
47+
containers = client.containers.list(all=True, filters={"ancestor": CODEGEN_RUNNER_IMAGE})
48+
sessions = []
49+
for container in containers:
50+
if container.attrs["Config"]["Image"] == CODEGEN_RUNNER_IMAGE:
51+
if container.status == "running":
52+
host_config = next(iter(container.ports.values()))[0]
53+
session = DockerSession(client=client, host=host_config["HostIp"], port=host_config["HostPort"], name=container.name)
54+
else:
55+
session = DockerSession(client=client, name=container.name)
56+
sessions.append(session)
57+
58+
return cls(sessions=sessions)
59+
except docker.errors.NotFound:
60+
return cls(sessions=[])
61+
62+
def get(self, name: str) -> DockerSession | None:
63+
return next((session for session in self.sessions if session.name == name), None)
64+
65+
def __str__(self) -> str:
66+
return f"DockerSessions(sessions={',\n'.join(str(session) for session in self.sessions)})"
67+
68+
69+
if __name__ == "__main__":
70+
docker_sessions = DockerSessions.load()
71+
print(docker_sessions)

src/codegen/cli/commands/start/main.py

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,26 @@
77
from rich.box import ROUNDED
88
from rich.panel import Panel
99

10+
from codegen.cli.commands.start.docker_session import CODEGEN_RUNNER_IMAGE, DockerSession, DockerSessions
1011
from codegen.configs.models.secrets import SecretsConfig
1112
from codegen.git.repo_operator.local_git_repo import LocalGitRepo
1213
from codegen.git.schemas.repo_config import RepoConfig
1314
from codegen.shared.network.port import get_free_port
1415

16+
_default_host = "0.0.0.0"
17+
1518

1619
@click.command(name="start")
1720
@click.option("--platform", "-t", type=click.Choice(["linux/amd64", "linux/arm64", "linux/amd64,linux/arm64"]), default="linux/amd64,linux/arm64", help="Target platform(s) for the Docker image")
1821
@click.option("--port", "-p", type=int, default=None, help="Port to run the server on")
19-
@click.option("--detached", "-d", is_flag=True, default=False, help="Starts up the server as detached background process")
20-
def start_command(port: int | None, platform: str, detached: bool):
22+
def start_command(port: int | None, platform: str):
2123
"""Starts a local codegen server"""
24+
repo_path = Path.cwd().resolve()
25+
repo_config = RepoConfig.from_repo_path(str(repo_path))
26+
docker_sessions = DockerSessions.load()
27+
if (existing_session := docker_sessions.get(repo_config.name)) is not None:
28+
return _handle_existing_session(repo_config, existing_session)
29+
2230
codegen_version = version("codegen")
2331
rich.print(f"[bold green]Codegen version:[/bold green] {codegen_version}")
2432
codegen_root = Path(__file__).parent.parent.parent.parent.parent.parent
@@ -29,8 +37,9 @@ def start_command(port: int | None, platform: str, detached: bool):
2937
rich.print("[bold blue]Building Docker image...[/bold blue]")
3038
_build_docker_image(codegen_root, platform)
3139
rich.print("[bold blue]Starting Docker container...[/bold blue]")
32-
_run_docker_container(port, detached)
33-
rich.print(Panel(f"[green]Server started successfully![/green]\nAccess the server at: [bold]http://0.0.0.0:{port}[/bold]", box=ROUNDED, title="Codegen Server"))
40+
_run_docker_container(repo_config, port)
41+
rich.print(Panel(f"[green]Server started successfully![/green]\nAccess the server at: [bold]http://{_default_host}:{port}[/bold]", box=ROUNDED, title="Codegen Server"))
42+
# TODO: memory snapshot here
3443
except subprocess.CalledProcessError as e:
3544
rich.print(f"[bold red]Error:[/bold red] Failed to {e.cmd[0]} Docker container")
3645
raise click.Abort()
@@ -39,7 +48,26 @@ def start_command(port: int | None, platform: str, detached: bool):
3948
raise click.Abort()
4049

4150

42-
def _build_docker_image(codegen_root: Path, platform: str):
51+
def _handle_existing_session(repo_config: RepoConfig, docker_session: DockerSession) -> None:
52+
if docker_session.is_running():
53+
rich.print(
54+
Panel(
55+
f"[green]Codegen server for {repo_config.name} is already running at: [bold]http://{docker_session.host}:{docker_session.port}[/bold][/green]",
56+
box=ROUNDED,
57+
title="Codegen Server",
58+
)
59+
)
60+
return
61+
62+
if docker_session.start():
63+
rich.print(Panel(f"[yellow]Docker session for {repo_config.name} is not running. Restarting...[/yellow]", box=ROUNDED, title="Docker Session"))
64+
return
65+
66+
rich.print(Panel(f"[red]Failed to restart container for {repo_config.name}[/red]", box=ROUNDED, title="Docker Session"))
67+
click.Abort()
68+
69+
70+
def _build_docker_image(codegen_root: Path, platform: str) -> None:
4371
build_cmd = [
4472
"docker",
4573
"buildx",
@@ -57,21 +85,19 @@ def _build_docker_image(codegen_root: Path, platform: str):
5785
subprocess.run(build_cmd, check=True)
5886

5987

60-
def _run_docker_container(port: int, detached: bool):
61-
repo_path = Path.cwd().resolve()
62-
repo_config = RepoConfig.from_repo_path(repo_path)
88+
def _run_docker_container(repo_config: RepoConfig, port: int) -> None:
6389
container_repo_path = f"/app/git/{repo_config.name}"
90+
name_args = ["--name", f"{repo_config.name}"]
6491
envvars = {
6592
"REPOSITORY_LANGUAGE": repo_config.language.value,
66-
"REPOSITORY_OWNER": LocalGitRepo(repo_path).owner,
93+
"REPOSITORY_OWNER": LocalGitRepo(repo_config.repo_path).owner,
6794
"REPOSITORY_PATH": container_repo_path,
6895
"GITHUB_TOKEN": SecretsConfig().github_token,
6996
}
7097
envvars_args = [arg for k, v in envvars.items() for arg in ("--env", f"{k}={v}")]
71-
mount_args = ["-v", f"{repo_path}:{container_repo_path}"]
72-
run_mode = "-d" if detached else "-it"
73-
entry_point = f"uv run --frozen uvicorn codegen.runner.sandbox.server:app --host 0.0.0.0 --port {port}"
74-
run_cmd = ["docker", "run", run_mode, "-p", f"{port}:{port}", *mount_args, *envvars_args, "codegen-runner", entry_point]
98+
mount_args = ["-v", f"{repo_config.repo_path}:{container_repo_path}"]
99+
entry_point = f"uv run --frozen uvicorn codegen.runner.sandbox.server:app --host {_default_host} --port {port}"
100+
run_cmd = ["docker", "run", "-d", "-p", f"{port}:{port}", *name_args, *mount_args, *envvars_args, CODEGEN_RUNNER_IMAGE, entry_point]
75101

76102
rich.print(f"run_cmd: {str.join(' ', run_cmd)}")
77103
subprocess.run(run_cmd, check=True)

src/codegen/runner/clients/client.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Client used to abstract the weird stdin/stdout communication we have with the sandbox"""
2+
3+
import logging
4+
5+
import requests
6+
from fastapi import params
7+
8+
logger = logging.getLogger(__name__)
9+
10+
DEFAULT_SERVER_PORT = 4002
11+
12+
EPHEMERAL_SERVER_PATH = "codegen.runner.sandbox.ephemeral_server:app"
13+
14+
15+
class Client:
16+
"""Client for interacting with the sandbox server."""
17+
18+
host: str
19+
port: int
20+
base_url: str
21+
22+
def __init__(self, host: str, port: int) -> None:
23+
self.host = host
24+
self.port = port
25+
self.base_url = f"http://{host}:{port}"
26+
27+
def healthcheck(self, raise_on_error: bool = True) -> bool:
28+
try:
29+
self.get("/")
30+
return True
31+
except requests.exceptions.ConnectionError:
32+
if raise_on_error:
33+
raise
34+
return False
35+
36+
def get(self, endpoint: str, data: dict | None = None) -> requests.Response:
37+
url = f"{self.base_url}{endpoint}"
38+
response = requests.get(url, json=data)
39+
response.raise_for_status()
40+
return response
41+
42+
def post(self, endpoint: str, data: dict | None = None, authorization: str | params.Header | None = None) -> requests.Response:
43+
url = f"{self.base_url}{endpoint}"
44+
headers = {"Authorization": str(authorization)} if authorization else None
45+
response = requests.post(url, json=data, headers=headers)
46+
response.raise_for_status()
47+
return response

src/codegen/runner/clients/codebase_client.py

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,70 @@
11
"""Client used to abstract the weird stdin/stdout communication we have with the sandbox"""
22

33
import logging
4+
import os
5+
import subprocess
6+
import time
47

58
from codegen.configs.models.secrets import SecretsConfig
69
from codegen.git.schemas.repo_config import RepoConfig
7-
from codegen.runner.clients.server_client import LocalServerClient
10+
from codegen.runner.clients.client import Client
811
from codegen.runner.models.apis import SANDBOX_SERVER_PORT
912

10-
logger = logging.getLogger(__name__)
11-
13+
DEFAULT_SERVER_PORT = 4002
14+
EPHEMERAL_SERVER_PATH = "codegen.runner.sandbox.ephemeral_server:app"
1215
RUNNER_SERVER_PATH = "codegen.runner.sandbox.server:app"
1316

1417

15-
class CodebaseClient(LocalServerClient):
18+
logger = logging.getLogger(__name__)
19+
20+
21+
class CodebaseClient(Client):
1622
"""Client for interacting with the locally hosted sandbox server."""
1723

1824
repo_config: RepoConfig
1925

20-
def __init__(self, repo_config: RepoConfig, host: str = "127.0.0.1", port: int = SANDBOX_SERVER_PORT):
26+
def __init__(self, repo_config: RepoConfig, host: str = "127.0.0.1", port: int = SANDBOX_SERVER_PORT, server_path: str = RUNNER_SERVER_PATH):
27+
super().__init__(host=host, port=port)
2128
self.repo_config = repo_config
22-
super().__init__(server_path=RUNNER_SERVER_PATH, host=host, port=port)
29+
self._process = None
30+
self._start_server(server_path)
31+
32+
def __del__(self):
33+
"""Cleanup the subprocess when the client is destroyed"""
34+
if self._process is not None:
35+
self._process.terminate()
36+
self._process.wait()
37+
38+
def _start_server(self, server_path: str) -> None:
39+
"""Start the FastAPI server in a subprocess"""
40+
envs = self._get_envs()
41+
logger.info(f"Starting local server on {self.base_url} with envvars: {envs}")
42+
43+
self._process = subprocess.Popen(
44+
[
45+
"uvicorn",
46+
server_path,
47+
"--host",
48+
self.host,
49+
"--port",
50+
str(self.port),
51+
],
52+
env=envs,
53+
)
54+
self._wait_for_server()
55+
56+
def _wait_for_server(self, timeout: int = 30, interval: float = 0.3) -> None:
57+
"""Wait for the server to start by polling the health endpoint"""
58+
start_time = time.time()
59+
while (time.time() - start_time) < timeout:
60+
if self.healthcheck(raise_on_error=False):
61+
return
62+
time.sleep(interval)
63+
msg = "Server failed to start within timeout period"
64+
raise TimeoutError(msg)
2365

2466
def _get_envs(self) -> dict:
25-
envs = super()._get_envs()
67+
envs = os.environ.copy()
2668
codebase_envs = {
2769
"REPOSITORY_LANGUAGE": self.repo_config.language.value,
2870
"REPOSITORY_OWNER": self.repo_config.organization_name,
Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Client for interacting with the locally hosted sandbox server hosted on a docker container."""
22

3-
from codegen.runner.clients.server_client import LocalServerClient
3+
from codegen.cli.commands.start.docker_session import DockerSession
4+
from codegen.runner.clients.client import Client
45

56

6-
class DockerClient(LocalServerClient):
7+
class DockerClient(Client):
78
"""Client for interacting with the locally hosted sandbox server hosted on a docker container."""
89

9-
def __init__(self, repo_config: RepoConfig):
10-
super().__init__(repo_config, host, port)
10+
def __init__(self, docker_session: DockerSession):
11+
super().__init__(docker_session.host, docker_session.port)

0 commit comments

Comments
 (0)