Skip to content

Commit 13c7886

Browse files
authored
feat: expose service statuses in cli (#134)
1 parent c262e78 commit 13c7886

7 files changed

Lines changed: 231 additions & 28 deletions

File tree

centml/cli/cluster.py

Lines changed: 93 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33
from typing import Dict
44
import click
55
from tabulate import tabulate
6-
from centml.sdk import DeploymentType, DeploymentStatus, ServiceStatus, ApiException, HardwareInstanceResponse
6+
from centml.sdk import (
7+
DeploymentType,
8+
DeploymentStatus,
9+
ServiceStatus,
10+
RolloutStatus,
11+
ApiException,
12+
HardwareInstanceResponse,
13+
)
714
from centml.sdk.api import get_centml_client
815

916
# convert deployment type enum to a user friendly name
@@ -27,6 +34,12 @@
2734
"compute": DeploymentType.COMPUTE_V2,
2835
"rag": DeploymentType.RAG,
2936
}
37+
rollout_status_to_service_status_map = {
38+
RolloutStatus.HEALTHY: ServiceStatus.HEALTHY,
39+
RolloutStatus.MISSING: ServiceStatus.MISSING,
40+
RolloutStatus.PROGRESSING: ServiceStatus.INITIALIZING,
41+
RolloutStatus.DEGRADED: ServiceStatus.ERROR,
42+
}
3043

3144

3245
def handle_exception(func):
@@ -75,18 +88,18 @@ def _get_replica_info(deployment):
7588
return {"min": "N/A", "max": "N/A"}
7689

7790

78-
def _get_ready_status(cclient, deployment):
91+
def _get_ready_status(deployment, service_status):
7992
api_status = deployment.status
80-
service_status = (
81-
cclient.get_status(deployment.id).service_status if deployment.status == DeploymentStatus.ACTIVE else None
82-
)
8393

8494
status_styles = {
8595
(DeploymentStatus.PAUSED, None): ("paused", "yellow", "black"),
8696
(DeploymentStatus.DELETED, None): ("deleted", "white", "black"),
8797
(DeploymentStatus.ACTIVE, ServiceStatus.HEALTHY): ("ready", "green", "black"),
98+
(DeploymentStatus.ACTIVE, ServiceStatus.SCALINGUP): ("starting", "black", "white"),
99+
(DeploymentStatus.ACTIVE, ServiceStatus.PULLING): ("starting", "black", "white"),
88100
(DeploymentStatus.ACTIVE, ServiceStatus.INITIALIZING): ("starting", "black", "white"),
89101
(DeploymentStatus.ACTIVE, ServiceStatus.MISSING): ("starting", "black", "white"),
102+
(DeploymentStatus.ACTIVE, ServiceStatus.NOTREADY): ("starting", "black", "white"),
90103
(DeploymentStatus.ACTIVE, ServiceStatus.ERROR): ("error", "red", "black"),
91104
(DeploymentStatus.ACTIVE, ServiceStatus.CREATECONTAINERCONFIGERROR): (
92105
"createContainerConfigError",
@@ -103,6 +116,62 @@ def _get_ready_status(cclient, deployment):
103116
return click.style(style[0], fg=style[1], bg=style[2])
104117

105118

119+
def _get_service_status(status_response, revision_number):
120+
if status_response is None:
121+
return None
122+
123+
service_status = getattr(status_response, "service_status", None)
124+
if service_status is not None:
125+
return service_status
126+
127+
revision_pod_details_list = getattr(status_response, "revision_pod_details_list", None) or []
128+
current_revision = next(
129+
(
130+
revision
131+
for revision in revision_pod_details_list
132+
if getattr(revision, "revision_number", None) == revision_number
133+
),
134+
(
135+
revision_pod_details_list[0]
136+
if revision_pod_details_list and getattr(revision_pod_details_list[0], "revision_number") is None
137+
else None
138+
),
139+
)
140+
revision_status = getattr(current_revision, "revision_status", None)
141+
142+
return revision_status or rollout_status_to_service_status_map.get(getattr(status_response, "rollout_status", None))
143+
144+
145+
def _append_status_error_message(messages, seen_messages, label, error_message):
146+
if not error_message or error_message in seen_messages:
147+
return
148+
149+
seen_messages.add(error_message)
150+
messages.append(f"{label}: {error_message}")
151+
152+
153+
def _get_status_error_messages(status_response):
154+
if status_response is None:
155+
return []
156+
157+
error_message = getattr(status_response, "error_message", None)
158+
if error_message:
159+
return [error_message]
160+
161+
messages = []
162+
seen_messages = set()
163+
164+
for revision in getattr(status_response, "revision_pod_details_list", None) or []:
165+
revision_label = f"revision {revision.revision_number}" if revision.revision_number is not None else "revision"
166+
_append_status_error_message(messages, seen_messages, revision_label, revision.error_message)
167+
168+
for pod in getattr(revision, "pod_details_list", None) or []:
169+
pod_label = pod.name or "pod"
170+
_append_status_error_message(messages, seen_messages, f"{revision_label} / {pod_label}", pod.error_message)
171+
172+
return messages
173+
174+
106175
@click.command(help="List all deployments")
107176
@click.argument("type", type=click.Choice(list(depl_name_to_type_map.keys())), required=False, default=None)
108177
def ls(type):
@@ -149,24 +218,27 @@ def get(type, id):
149218
else:
150219
sys.exit("Please enter correct deployment type")
151220

152-
ready_status = _get_ready_status(cclient, deployment)
221+
deployment_status = cclient.get_status(deployment.id) if deployment.status == DeploymentStatus.ACTIVE else None
222+
revision_number = getattr(deployment, "revision_number", None)
223+
service_status = _get_service_status(deployment_status, revision_number)
224+
ready_status = _get_ready_status(deployment, service_status)
225+
status_error_messages = _get_status_error_messages(deployment_status)
153226
_, id_to_hw_map = _get_hw_to_id_map(cclient, deployment.cluster_id)
154227
hw = id_to_hw_map[deployment.hardware_instance_id]
155-
156-
click.echo(
157-
tabulate(
158-
[
159-
("Name", deployment.name),
160-
("Status", ready_status),
161-
("Endpoint", deployment.endpoint_url),
162-
("Created at", deployment.created_at.strftime("%Y-%m-%d %H:%M:%S")),
163-
("Hardware", f"{hw.name} ({hw.num_gpu}x {hw.gpu_type})"),
164-
("Cost", f"{hw.cost_per_hr / 100} credits/hr"),
165-
],
166-
tablefmt="rounded_outline",
167-
disable_numparse=True,
168-
)
169-
)
228+
detail_rows = [
229+
("Name", deployment.name),
230+
("Status", ready_status),
231+
("Endpoint", deployment.endpoint_url),
232+
("Created at", deployment.created_at.strftime("%Y-%m-%d %H:%M:%S")),
233+
("Hardware", f"{hw.name} ({hw.num_gpu}x {hw.gpu_type})"),
234+
("Cost", f"{hw.cost_per_hr / 100} credits/hr"),
235+
]
236+
237+
click.echo(tabulate(detail_rows, tablefmt="rounded_outline", disable_numparse=True))
238+
if status_error_messages:
239+
click.echo("\nStatus errors:")
240+
for message in status_error_messages:
241+
click.echo(f"- {message}")
170242

171243
click.echo("Additional deployment configurations:")
172244
if depl_type in [DeploymentType.INFERENCE_V2, DeploymentType.INFERENCE_V3]:

centml/compiler/prediction/__init__.py

Whitespace-only changes.

centml/sdk/api.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
from centml.sdk import auth
1616
from centml.sdk.config import settings
1717

18+
STATUS_V3_DEPLOYMENT_TYPES = {DeploymentType.INFERENCE_V3, DeploymentType.CSERVE_V3}
19+
1820

1921
class CentMLClient:
2022
def __init__(self, api):
@@ -26,10 +28,15 @@ def get(self, depl_type):
2628
return deployments
2729

2830
def get_status(self, id):
29-
return self._api.get_deployment_status_deployments_status_deployment_id_get(id)
30-
31-
def get_status_v3(self, deployment_id):
32-
return self._api.get_deployment_status_v3_deployments_status_v3_deployment_id_get(deployment_id)
31+
try:
32+
return self._api.get_deployment_status_v3_deployments_status_v3_deployment_id_get(id)
33+
except ApiException as e:
34+
if e.status in [404, 400]:
35+
try:
36+
return self._api.get_deployment_status_deployments_status_deployment_id_get(id)
37+
except ApiException as v2_error:
38+
raise e from v2_error
39+
raise
3340

3441
def get_inference(self, id):
3542
"""Get Inference deployment details - automatically handles both V2 and V3 deployments"""

centml/sdk/shell/session.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ def build_ws_url(api_url, deployment_id, pod_name, shell_type=None):
3737

3838

3939
def get_running_pods(cclient, deployment_id) -> list[str]:
40-
status = cclient.get_status_v3(deployment_id)
40+
status = cclient.get_status(deployment_id)
4141
running_pods = []
42-
for revision in status.revision_pod_details_list or []:
42+
for revision in getattr(status, "revision_pod_details_list", None) or []:
4343
for pod in revision.pod_details_list or []:
4444
if pod.status == PodStatus.RUNNING and pod.name:
4545
running_pods.append(pod.name)

requirements-dev.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
-r requirements.txt
22

33
torch==2.8.0
4-
black>=23.10.0
4+
black==26.3.1
55
pylint>=3.0.1
66
pytest>=7.4.0
77
pytest-env>=1.1.3

tests/test_cli_cluster.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from types import SimpleNamespace
2+
3+
from centml.cli.cluster import _get_service_status, _get_status_error_messages
4+
from centml.sdk import RolloutStatus, ServiceStatus
5+
6+
7+
def test_service_status_uses_legacy_service_status_when_present():
8+
status_response = SimpleNamespace(service_status=ServiceStatus.HEALTHY)
9+
10+
assert _get_service_status(status_response, revision_number=None) == ServiceStatus.HEALTHY
11+
12+
13+
def test_service_status_maps_v3_healthy_rollout_status():
14+
status_response = SimpleNamespace(rollout_status=RolloutStatus.HEALTHY)
15+
16+
assert _get_service_status(status_response, revision_number=None) == ServiceStatus.HEALTHY
17+
18+
19+
def test_service_status_uses_current_v3_revision_status():
20+
status_response = SimpleNamespace(
21+
rollout_status=RolloutStatus.PROGRESSING,
22+
revision_pod_details_list=[
23+
SimpleNamespace(revision_number=1, revision_status=ServiceStatus.HEALTHY),
24+
SimpleNamespace(revision_number=2, revision_status=ServiceStatus.SCALINGUP),
25+
],
26+
)
27+
28+
assert _get_service_status(status_response, revision_number=2) == ServiceStatus.SCALINGUP
29+
30+
31+
def test_service_status_uses_v3_revision_without_revision_number_as_fallback():
32+
status_response = SimpleNamespace(
33+
rollout_status=RolloutStatus.DEGRADED,
34+
revision_pod_details_list=[
35+
SimpleNamespace(revision_number=None, revision_status=ServiceStatus.IMAGEPULLBACKOFF)
36+
],
37+
)
38+
39+
assert _get_service_status(status_response, revision_number=2) == ServiceStatus.IMAGEPULLBACKOFF
40+
41+
42+
def test_status_error_messages_include_revision_and_pod_messages():
43+
status_response = SimpleNamespace(
44+
revision_pod_details_list=[
45+
SimpleNamespace(
46+
revision_number=3,
47+
error_message="revision failed",
48+
pod_details_list=[
49+
SimpleNamespace(name="pod-a", error_message="image pull failed"),
50+
SimpleNamespace(name="pod-b", error_message=None),
51+
],
52+
)
53+
]
54+
)
55+
56+
messages = _get_status_error_messages(status_response)
57+
58+
assert messages == ["revision 3: revision failed", "revision 3 / pod-a: image pull failed"]
59+
60+
61+
def test_status_error_messages_do_not_repeat_duplicate_messages():
62+
duplicate_message = "one or more objects failed to apply"
63+
status_response = SimpleNamespace(
64+
revision_pod_details_list=[
65+
SimpleNamespace(
66+
revision_number=None,
67+
error_message=duplicate_message,
68+
pod_details_list=[SimpleNamespace(name=None, error_message=duplicate_message)],
69+
)
70+
]
71+
)
72+
73+
assert _get_status_error_messages(status_response) == [f"revision: {duplicate_message}"]
74+
75+
76+
def test_status_error_messages_include_legacy_status_message():
77+
status_response = SimpleNamespace(error_message="legacy service failure")
78+
79+
assert _get_status_error_messages(status_response) == ["legacy service failure"]

tests/test_sdk_api.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from types import SimpleNamespace
2+
from unittest.mock import MagicMock
3+
4+
from centml.sdk import ApiException
5+
from centml.sdk.api import CentMLClient
6+
7+
8+
def test_get_status_uses_v3_endpoint():
9+
api = MagicMock()
10+
expected_status = SimpleNamespace()
11+
api.get_deployment_status_v3_deployments_status_v3_deployment_id_get.return_value = expected_status
12+
13+
assert CentMLClient(api).get_status(123) is expected_status
14+
15+
api.get_deployment_status_v3_deployments_status_v3_deployment_id_get.assert_called_once_with(123)
16+
api.get_deployment_status_deployments_status_deployment_id_get.assert_not_called()
17+
18+
19+
def test_get_status_falls_back_to_legacy_endpoint_when_v3_is_not_found():
20+
api = MagicMock()
21+
expected_status = SimpleNamespace()
22+
api.get_deployment_status_v3_deployments_status_v3_deployment_id_get.side_effect = ApiException(status=404)
23+
api.get_deployment_status_deployments_status_deployment_id_get.return_value = expected_status
24+
25+
assert CentMLClient(api).get_status(123) is expected_status
26+
27+
api.get_deployment_status_v3_deployments_status_v3_deployment_id_get.assert_called_once_with(123)
28+
api.get_deployment_status_deployments_status_deployment_id_get.assert_called_once_with(123)
29+
30+
31+
def test_get_status_raises_v3_error_when_both_status_endpoints_fail():
32+
api = MagicMock()
33+
v3_error = ApiException(status=404)
34+
api.get_deployment_status_v3_deployments_status_v3_deployment_id_get.side_effect = v3_error
35+
api.get_deployment_status_deployments_status_deployment_id_get.side_effect = ApiException(status=404)
36+
37+
try:
38+
CentMLClient(api).get_status(123)
39+
except ApiException as e:
40+
assert e is v3_error
41+
else:
42+
raise AssertionError("Expected ApiException")
43+
44+
api.get_deployment_status_v3_deployments_status_v3_deployment_id_get.assert_called_once_with(123)
45+
api.get_deployment_status_deployments_status_deployment_id_get.assert_called_once_with(123)

0 commit comments

Comments
 (0)