Skip to content

Commit 994afb2

Browse files
authored
feat: Add ability to configure host IP during tesseract serve (#185)
#### Relevant issue or PR n/a #### Description of changes - Serve on `127.0.0.1` (localhost) by default when using `tesseract-runtime serve`, to avoid accidentally exposing Tesseracts to the local or public internet when using `tesseract-runtime` directly. - Add capability to configure host IP when serving containers via `tesseract serve`. To-do: - [x] Also expose in Python API - [x] Add tests #### Testing done CI + manual
1 parent 2231b34 commit 994afb2

10 files changed

Lines changed: 145 additions & 26 deletions

File tree

docs/content/introduction/get-started.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,15 @@ $ tesseract serve -p 8080 helloworld
4545
[i] Container ID: 2587deea2a2efb6198913f757772560d9c64cf8621a6d1a54aa3333a7b4bcf62
4646
[i] Name: tesseract-uum375qt6dj5-sha256-9by9ahsnsza2-1
4747
[i] Entrypoint: ['tesseract-runtime', 'serve']
48-
[i] View Tesseract: http://localhost:56489/docs
48+
[i] View Tesseract: http://127.0.0.1:56489/docs
4949
[i] Docker Compose Project ID, use it with 'tesseract teardown' command: tesseract-u7um375qt6dj5
5050
{"project_id": "tesseract-u7um375qt6dj5", "containers": [{"name": "tesseract-uum375qt6dj5-sha256-9by9ahsnsza2-1", "port": "8080"}]}%
5151

5252
$ # The port at which your Tesseract will be served is random if `--port` is not specified;
5353
$ # specify the one you received from `tesseract serve` output in the next command.
5454
$ curl -d '{"inputs": {"name": "Osborne"}}' \
5555
-H "Content-Type: application/json" \
56-
http://localhost:8080/apply
56+
http://127.0.0.1:8080/apply
5757
{"greeting":"Hello Osborne!"}
5858

5959
$ tesseract teardown tesseract-9hj8fyxrx073
@@ -86,7 +86,7 @@ $ tesseract run helloworld --help
8686
```bash
8787
$ tesseract apidoc helloworld
8888
[i] Waiting for Tesseract containers to start ...
89-
[i] Serving OpenAPI docs for Tesseract helloworld at http://localhost:59569/docs
89+
[i] Serving OpenAPI docs for Tesseract helloworld at http://127.0.0.1:59569/docs
9090
[i] Press Ctrl+C to stop
9191
```
9292
:::

tesseract_core/runtime/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ def check_gradients(
309309

310310
@tesseract_runtime.command()
311311
@click.option("-p", "--port", default=8000, help="Port number")
312-
@click.option("-h", "--host", default="0.0.0.0", help="Host IP address")
312+
@click.option("-h", "--host", default="127.0.0.1", help="Host IP address")
313313
@click.option("-w", "--num-workers", default=1, help="Number of worker processes")
314314
def serve(host: str, port: int, num_workers: int) -> None:
315315
"""Start running this Tesseract's web server."""

tesseract_core/sdk/cli.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,17 @@ def serve(
417417
callback=_validate_port,
418418
),
419419
] = None,
420+
host_ip: Annotated[
421+
str,
422+
typer.Option(
423+
"--host-ip",
424+
help=(
425+
"IP address of the host to bind the Tesseract to. "
426+
"Defaults to 127.0.0.1 (localhost). To bind to all interfaces, use '0.0.0.0'. "
427+
"WARNING: This may expose Tesseract to all local networks, use with caution."
428+
),
429+
),
430+
] = "127.0.0.1",
420431
gpus: Annotated[
421432
list[str] | None,
422433
typer.Option(
@@ -483,6 +494,7 @@ def serve(
483494
try:
484495
project_id = engine.serve(
485496
image_names,
497+
host_ip,
486498
ports,
487499
volume,
488500
gpus,
@@ -538,7 +550,9 @@ def _display_tesseract_image_meta() -> None:
538550

539551
def _display_tesseract_containers_meta() -> None:
540552
"""Display Tesseract containers metadata."""
541-
table = RichTable("ID", "Name", "Version", "Host Port", "Project ID", "Description")
553+
table = RichTable(
554+
"ID", "Name", "Version", "Host Address", "Project ID", "Description"
555+
)
542556
containers = docker_client.containers.list()
543557
for container in containers:
544558
tesseract_vals = _get_tesseract_env_vals(container)
@@ -548,7 +562,7 @@ def _display_tesseract_containers_meta() -> None:
548562
container.id[:12],
549563
tesseract_vals["TESSERACT_NAME"],
550564
tesseract_vals["TESSERACT_VERSION"],
551-
container.host_port,
565+
f"{container.host_ip}:{container.host_port}",
552566
tesseract_project,
553567
tesseract_vals.get("TESSERACT_DESCRIPTION", "").replace("\\n", " "),
554568
)
@@ -595,10 +609,11 @@ def apidoc(
595609
] = True,
596610
) -> None:
597611
"""Serve the OpenAPI schema for a Tesseract."""
598-
project_id = engine.serve([image_name], no_compose=True)
612+
host_ip = "127.0.0.1"
613+
project_id = engine.serve([image_name], no_compose=True, host_ip=host_ip)
599614
try:
600615
container = docker_client.containers.get(project_id)
601-
url = f"http://localhost:{container.host_port}/docs"
616+
url = f"http://{host_ip}:{container.host_port}/docs"
602617
logger.info(f"Serving OpenAPI docs for Tesseract {image_name} at {url}")
603618
logger.info(" Press Ctrl+C to stop")
604619
if browser:
@@ -632,8 +647,11 @@ def _display_project_meta(project_id: str) -> list:
632647
entrypoint = container.attrs["Config"]["Entrypoint"]
633648
logger.info(f"Entrypoint: {entrypoint}")
634649
host_port = container.host_port
635-
logger.info(f"View Tesseract: http://localhost:{host_port}/docs")
636-
container_ports.append({"name": container.name, "port": host_port})
650+
host_ip = container.host_ip
651+
logger.info(f"View Tesseract: http://{host_ip}:{host_port}/docs")
652+
container_ports.append(
653+
{"name": container.name, "port": host_port, "ip": host_ip}
654+
)
637655

638656
return container_ports
639657

tesseract_core/sdk/docker_client.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,9 @@ def _get_images(tesseract_only: bool = True) -> list_[Image]:
263263
class Container:
264264
"""Container class to wrap Docker container details.
265265
266-
Container class has additional member variable `host_port` that docker-py
267-
does not have. This is because Tesseract requires frequent access to the host port.
266+
Container class has additional member variables `host_port` and `host_ip` that
267+
docker-py does not have. This is because Tesseract requires frequent access to the host
268+
port mappings.
268269
"""
269270

270271
id: str
@@ -301,6 +302,17 @@ def host_port(self) -> str | None:
301302
return ports[port_key][0].get("HostPort")
302303
return None
303304

305+
@property
306+
def host_ip(self) -> str | None:
307+
"""Gets the host IP of the container."""
308+
if self.attrs.get("NetworkSettings", None):
309+
ports = self.attrs["NetworkSettings"].get("Ports", None)
310+
if ports:
311+
port_key = next(iter(ports)) # Get the first port key
312+
if ports[port_key]:
313+
return ports[port_key][0].get("HostIp")
314+
return None
315+
304316
@property
305317
def project_id(self) -> str | None:
306318
"""Gets the project ID of the container."""

tesseract_core/sdk/engine.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def wrapper_needs_docker(*args: Any, **kwargs: Any) -> None:
119119

120120

121121
def get_free_port(
122-
within_range: tuple[int, int] | None = (49152, 65535),
122+
within_range: tuple[int, int] = (49152, 65535),
123123
exclude: Sequence[int] = (),
124124
) -> int:
125125
"""Find a random free port to use for HTTP."""
@@ -136,7 +136,7 @@ def get_free_port(
136136
# Check if the port is free
137137
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
138138
try:
139-
s.bind(("localhost", port))
139+
s.bind(("127.0.0.1", port))
140140
except OSError:
141141
# Port is already in use
142142
continue
@@ -537,6 +537,7 @@ def get_project_containers(project_id: str) -> list[Container]:
537537

538538
def serve(
539539
images: list[str],
540+
host_ip: str = "127.0.0.1",
540541
ports: list[str] | None = None,
541542
volumes: list[str] | None = None,
542543
gpus: list[str] | None = None,
@@ -550,6 +551,7 @@ def serve(
550551
551552
Args:
552553
images: a list of Tesseract image IDs as strings.
554+
host_ip: IP address to bind the Tesseracts to.
553555
ports: port or port range to serve each Tesseract on.
554556
volumes: list of paths to mount in the Tesseract container.
555557
gpus: IDs of host Nvidia GPUs to make available to the Tesseracts.
@@ -597,22 +599,30 @@ def serve(
597599
if propagate_tracebacks:
598600
args.append("--debug")
599601

600-
logger.info(f"Serving Tesseract at http://localhost:{port}")
601-
logger.info(f"View Tesseract: http://localhost:{port}/docs")
602+
# Always bind to all interfaces inside the container
603+
args.extend(["--host", "0.0.0.0"])
604+
605+
if host_ip == "0.0.0.0":
606+
ping_ip = "127.0.0.1"
607+
else:
608+
ping_ip = host_ip
609+
610+
logger.info(f"Serving Tesseract at http://{ping_ip}:{port}")
611+
logger.info(f"View Tesseract: http://{ping_ip}:{port}/docs")
602612

603613
container = docker_client.containers.run(
604614
image=image_ids[0],
605615
command=["serve", *args],
606616
device_requests=gpus,
607-
ports={f"{port}": "8000"},
617+
ports={f"{host_ip}:{port}": container_port},
608618
detach=True,
609619
volumes=volumes,
610620
)
611621
# wait for server to start
612622
timeout = 30
613623
while True:
614624
try:
615-
response = requests.get(f"http://localhost:{port}/health")
625+
response = requests.get(f"http://{ping_ip}:{port}/health")
616626
except requests.exceptions.ConnectionError:
617627
pass
618628
else:
@@ -628,7 +638,13 @@ def serve(
628638
return container.name
629639

630640
template = _create_docker_compose_template(
631-
image_ids, ports, volumes, gpus, num_workers, debug=propagate_tracebacks
641+
image_ids,
642+
host_ip,
643+
ports,
644+
volumes,
645+
gpus,
646+
num_workers,
647+
debug=propagate_tracebacks,
632648
)
633649
compose_fname = f"docker-compose-{_id_generator()}.yml"
634650

@@ -647,6 +663,7 @@ def serve(
647663

648664
def _create_docker_compose_template(
649665
image_ids: list[str],
666+
host_ip: str = "127.0.0.1",
650667
ports: list[str] | None = None,
651668
volumes: list[str] | None = None,
652669
gpus: list[str] | None = None,
@@ -674,6 +691,9 @@ def _create_docker_compose_template(
674691
)
675692
)
676693

694+
# Prepend host IP to ports
695+
ports = [f"{host_ip}:{port}" for port in ports]
696+
677697
gpu_settings = None
678698
if gpus:
679699
if (len(gpus) == 1) and (gpus[0] == "all"):
@@ -691,11 +711,12 @@ def _create_docker_compose_template(
691711
"environment": {
692712
"TESSERACT_DEBUG": "1" if debug else "0",
693713
},
714+
"num_workers": num_workers,
694715
}
695716

696717
services.append(service)
697718
template = ENV.get_template("docker-compose.yml")
698-
return template.render(services=services, num_workers=num_workers)
719+
return template.render(services=services)
699720

700721

701722
def _id_generator(

tesseract_core/sdk/templates/docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ services:
33
{{ service.name }}:
44
image: {{ service.image }}
55
restart: unless-stopped
6-
command: ["serve", "--num-workers", "{{ num_workers }}"]
6+
command: ["serve", "--host", "0.0.0.0", "--num-workers", "{{ service.num_workers }}"]
77
ports:
88
- {{ service.port }}
99
{%- if service.volumes %}

tesseract_core/sdk/tesseract.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,11 +204,12 @@ def server_logs(self) -> str:
204204
return self._lastlog
205205
return engine.logs(self._serve_context["container_id"])
206206

207-
def serve(self, port: str | None = None) -> None:
207+
def serve(self, port: str | None = None, host_ip: str = "127.0.0.1") -> None:
208208
"""Serve the Tesseract.
209209
210210
Args:
211211
port: Port to serve the Tesseract on.
212+
host_ip: IP address of the host to bind the Tesseract to.
212213
"""
213214
if self._spawn_config is None:
214215
raise RuntimeError("Can only serve a Tesseract created via from_image.")
@@ -221,14 +222,15 @@ def serve(self, port: str | None = None) -> None:
221222
gpus=self._spawn_config.gpus,
222223
num_workers=self._spawn_config.num_workers,
223224
debug=self._spawn_config.debug,
225+
host_ip=host_ip,
224226
)
225227
self._serve_context = dict(
226228
project_id=project_id,
227229
container_id=container_id,
228230
port=served_port,
229231
)
230232
self._lastlog = None
231-
self._client = HTTPClient(f"http://localhost:{served_port}")
233+
self._client = HTTPClient(f"http://{host_ip}:{served_port}")
232234
atexit.register(self.teardown)
233235

234236
def teardown(self) -> None:
@@ -256,6 +258,7 @@ def __del__(self) -> None:
256258
def _serve(
257259
image: str,
258260
port: str | None = None,
261+
host_ip: str = "127.0.0.1",
259262
volumes: list[str] | None = None,
260263
gpus: list[str] | None = None,
261264
debug: bool = False,
@@ -273,6 +276,7 @@ def _serve(
273276
gpus=gpus,
274277
propagate_tracebacks=debug,
275278
num_workers=num_workers,
279+
host_ip=host_ip,
276280
)
277281

278282
first_container = engine.get_project_containers(project_id)[0]

tests/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def free_port():
196196
from contextlib import closing
197197

198198
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
199-
s.bind(("localhost", 0))
199+
s.bind(("127.0.0.1", 0))
200200
return s.getsockname()[1]
201201

202202

@@ -246,7 +246,7 @@ def cleanup_func():
246246
for container in context["containers"]:
247247
try:
248248
if isinstance(container, str):
249-
container_obj = docker_client.containers.get(container.id)
249+
container_obj = docker_client.containers.get(container)
250250
else:
251251
container_obj = container
252252

0 commit comments

Comments
 (0)