Skip to content

Commit 91ed0a4

Browse files
authored
feat: add tutor dev hosts command to display status of services (#1223)
1 parent 816e2af commit 91ed0a4

File tree

4 files changed

+95
-8
lines changed

4 files changed

+95
-8
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- [Feature] Add new command `tutor dev hosts` that displays status of services. (by @mlabeeb03)

tests/commands/test_dev.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import unittest
22

3+
from tutor.commands import dev
4+
35
from .base import TestCommandMixin
46

57

@@ -8,3 +10,35 @@ def test_dev_help(self) -> None:
810
result = self.invoke(["dev", "--help"])
911
self.assertEqual(0, result.exit_code)
1012
self.assertIsNone(result.exception)
13+
14+
15+
class HostsParsing(unittest.TestCase):
16+
def test_host_port_single(self) -> None:
17+
ps_output = """{"Publishers":[{"PublishedPort":8000}]}"""
18+
host_port = dev.parse_ports(ps_output)
19+
self.assertEqual(host_port, [8000])
20+
21+
def test_host_port_single_empty(self) -> None:
22+
ps_output = """{"Publishers":[{"PublishedPort":0}]}"""
23+
host_port = dev.parse_ports(ps_output)
24+
self.assertEqual(host_port, [])
25+
26+
def test_host_port_multiple_services(self) -> None:
27+
ps_output = """{"Publishers":[{"PublishedPort":0}]}
28+
{"Publishers":[{"PublishedPort":8000}]}
29+
{"Publishers":[{"PublishedPort":8001}]}"""
30+
31+
host_port = dev.parse_ports(ps_output)
32+
self.assertEqual(host_port, [8000, 8001])
33+
34+
def test_host_port_multiple_publishers(self) -> None:
35+
ps_output = """{"Publishers":[{"PublishedPort":0}, {"PublishedPort":3276}, {"PublishedPort":8000}]}"""
36+
37+
host_port = dev.parse_ports(ps_output)
38+
self.assertEqual(host_port, [3276, 8000])
39+
40+
def test_host_port_multiple_services_publishers(self) -> None:
41+
ps_output = """{"Publishers":[{"PublishedPort":0}, {"PublishedPort":3276}, {"PublishedPort":8000}]}
42+
{"Publishers":[{"PublishedPort":0}, {"PublishedPort":8001}, {"PublishedPort":8002}]}"""
43+
host_port = dev.parse_ports(ps_output)
44+
self.assertEqual(host_port, [3276, 8000, 8001, 8002])

tutor/commands/compose.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,10 @@
55

66
import click
77

8-
from tutor import bindmount
8+
from tutor import bindmount, fmt, hooks, utils
99
from tutor import config as tutor_config
1010
from tutor import env as tutor_env
11-
from tutor import fmt, hooks
1211
from tutor import interactive as interactive_config
13-
from tutor import utils
1412
from tutor.commands import images, jobs
1513
from tutor.commands.config import save as config_save_command
1614
from tutor.commands.context import BaseTaskContext
@@ -31,9 +29,9 @@ def __init__(self, root: str, config: Config):
3129
self.docker_compose_files: list[str] = []
3230
self.docker_compose_job_files: list[str] = []
3331

34-
def docker_compose(self, *command: str) -> int:
32+
def _get_docker_compose_args(self, *command: str) -> list[str]:
3533
"""
36-
Run docker-compose with the right yml files.
34+
Returns appropriate arguments (yml files) to be used with the docker compose commmand
3735
"""
3836
# Trigger the action just once per runtime
3937
start_commands = ("start", "up", "restart", "run")
@@ -48,8 +46,21 @@ def docker_compose(self, *command: str) -> int:
4846
for docker_compose_path in self.docker_compose_files:
4947
if os.path.exists(docker_compose_path):
5048
args += ["-f", docker_compose_path]
51-
return utils.docker_compose(
52-
*args, "--project-name", self.project_name, *command
49+
args += ["--project-name", self.project_name, *command]
50+
return args
51+
52+
def docker_compose(self, *command: str) -> int:
53+
"""
54+
Run docker-compose with the right yml files.
55+
"""
56+
return utils.docker_compose(*self._get_docker_compose_args(*command))
57+
58+
def docker_compose_output(self, *command: str) -> bytes:
59+
"""
60+
Same as the docker_compose method except that it returns the command output.
61+
"""
62+
return utils.check_output(
63+
"docker", "compose", *self._get_docker_compose_args(*command)
5364
)
5465

5566
def run_task(self, service: str, command: str) -> int:

tutor/commands/dev.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from __future__ import annotations
22

3+
import json
34
import typing as t
5+
from urllib.parse import urlparse
46

57
import click
68

9+
from tutor import config as tutor_config
710
from tutor import env as tutor_env
8-
from tutor import hooks
11+
from tutor import fmt, hooks, utils
912
from tutor.commands import compose
1013
from tutor.types import Config, get_typed
1114
from tutor.utils import get_compose_stacks
@@ -45,6 +48,44 @@ def dev(context: click.Context) -> None:
4548
context.obj = DevContext(context.obj.root)
4649

4750

51+
def parse_ports(docker_compose_ps_output: str) -> list[int]:
52+
"""
53+
Extracts the ports from the docker ps output in json format.
54+
"""
55+
exposed_ports = []
56+
for line in docker_compose_ps_output.splitlines():
57+
publishers = json.loads(line)["Publishers"]
58+
for publisher in publishers:
59+
port = publisher["PublishedPort"]
60+
if port:
61+
exposed_ports.append(port)
62+
return exposed_ports
63+
64+
65+
@click.command(help="List the status of all services.")
66+
@click.pass_obj
67+
def hosts(context: compose.BaseComposeContext) -> None:
68+
config = tutor_config.load(context.root)
69+
docker_compose_ps_output = (
70+
context.job_runner(config)
71+
.docker_compose_output("ps", "--format", "json")
72+
.decode()
73+
)
74+
exposed_ports = parse_ports(docker_compose_ps_output)
75+
public_app_hosts: list[tuple[str, ...]] = [("URL", "STATUS")]
76+
for host in hooks.Filters.APP_PUBLIC_HOSTS.iterate(context.NAME):
77+
public_app_host = tutor_env.render_str(
78+
config, "{% if ENABLE_HTTPS %}https{% else %}http{% endif %}://" + host
79+
)
80+
port = urlparse(public_app_host).port
81+
status_marker = " ✅" if port in exposed_ports else ""
82+
public_app_hosts.append((public_app_host, status_marker))
83+
fmt.echo(utils.format_table(public_app_hosts))
84+
85+
86+
dev.add_command(hosts)
87+
88+
4889
@hooks.Actions.COMPOSE_PROJECT_STARTED.add()
4990
def _stop_on_local_start(root: str, config: Config, project_name: str) -> None:
5091
"""

0 commit comments

Comments
 (0)