devservices 1.0.18__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {devservices-1.0.18 → devservices-1.1.0}/PKG-INFO +1 -1
  2. {devservices-1.0.18 → devservices-1.1.0}/README.md +1 -1
  3. {devservices-1.0.18 → devservices-1.1.0}/devservices/commands/down.py +19 -12
  4. devservices-1.1.0/devservices/commands/status.py +367 -0
  5. devservices-1.1.0/devservices/commands/toggle.py +267 -0
  6. {devservices-1.0.18 → devservices-1.1.0}/devservices/commands/up.py +33 -5
  7. {devservices-1.0.18 → devservices-1.1.0}/devservices/constants.py +13 -0
  8. {devservices-1.0.18 → devservices-1.1.0}/devservices/exceptions.py +34 -0
  9. {devservices-1.0.18 → devservices-1.1.0}/devservices/main.py +2 -0
  10. {devservices-1.0.18 → devservices-1.1.0}/devservices/utils/console.py +2 -11
  11. {devservices-1.0.18 → devservices-1.1.0}/devservices/utils/dependencies.py +54 -28
  12. {devservices-1.0.18 → devservices-1.1.0}/devservices/utils/docker_compose.py +4 -1
  13. {devservices-1.0.18 → devservices-1.1.0}/devservices/utils/state.py +3 -3
  14. devservices-1.1.0/devservices/utils/supervisor.py +134 -0
  15. {devservices-1.0.18 → devservices-1.1.0}/devservices.egg-info/PKG-INFO +1 -1
  16. {devservices-1.0.18 → devservices-1.1.0}/devservices.egg-info/SOURCES.txt +5 -1
  17. {devservices-1.0.18 → devservices-1.1.0}/pyproject.toml +1 -1
  18. devservices-1.1.0/tests/commands/test_status.py +842 -0
  19. devservices-1.1.0/tests/commands/test_toggle.py +1496 -0
  20. {devservices-1.0.18 → devservices-1.1.0}/tests/commands/test_up.py +823 -184
  21. {devservices-1.0.18 → devservices-1.1.0}/tests/utils/test_dependencies.py +369 -30
  22. {devservices-1.0.18 → devservices-1.1.0}/tests/utils/test_docker_compose.py +6 -2
  23. devservices-1.1.0/tests/utils/test_supervisor.py +189 -0
  24. devservices-1.0.18/devservices/commands/status.py +0 -180
  25. devservices-1.0.18/tests/commands/test_status.py +0 -266
  26. {devservices-1.0.18 → devservices-1.1.0}/LICENSE.md +0 -0
  27. {devservices-1.0.18 → devservices-1.1.0}/devservices/__init__.py +0 -0
  28. {devservices-1.0.18 → devservices-1.1.0}/devservices/commands/__init__.py +0 -0
  29. {devservices-1.0.18 → devservices-1.1.0}/devservices/commands/list_dependencies.py +0 -0
  30. {devservices-1.0.18 → devservices-1.1.0}/devservices/commands/list_services.py +0 -0
  31. {devservices-1.0.18 → devservices-1.1.0}/devservices/commands/logs.py +0 -0
  32. {devservices-1.0.18 → devservices-1.1.0}/devservices/commands/purge.py +0 -0
  33. {devservices-1.0.18 → devservices-1.1.0}/devservices/commands/update.py +0 -0
  34. {devservices-1.0.18 → devservices-1.1.0}/devservices/configs/service_config.py +0 -0
  35. {devservices-1.0.18 → devservices-1.1.0}/devservices/utils/__init__.py +0 -0
  36. {devservices-1.0.18 → devservices-1.1.0}/devservices/utils/check_for_update.py +0 -0
  37. {devservices-1.0.18 → devservices-1.1.0}/devservices/utils/devenv.py +0 -0
  38. {devservices-1.0.18 → devservices-1.1.0}/devservices/utils/docker.py +0 -0
  39. {devservices-1.0.18 → devservices-1.1.0}/devservices/utils/file_lock.py +0 -0
  40. {devservices-1.0.18 → devservices-1.1.0}/devservices/utils/git.py +0 -0
  41. {devservices-1.0.18 → devservices-1.1.0}/devservices/utils/install_binary.py +0 -0
  42. {devservices-1.0.18 → devservices-1.1.0}/devservices/utils/services.py +0 -0
  43. {devservices-1.0.18 → devservices-1.1.0}/devservices.egg-info/dependency_links.txt +0 -0
  44. {devservices-1.0.18 → devservices-1.1.0}/devservices.egg-info/entry_points.txt +0 -0
  45. {devservices-1.0.18 → devservices-1.1.0}/devservices.egg-info/requires.txt +0 -0
  46. {devservices-1.0.18 → devservices-1.1.0}/devservices.egg-info/top_level.txt +0 -0
  47. {devservices-1.0.18 → devservices-1.1.0}/setup.cfg +0 -0
  48. {devservices-1.0.18 → devservices-1.1.0}/testing/__init__.py +0 -0
  49. {devservices-1.0.18 → devservices-1.1.0}/testing/utils.py +0 -0
  50. {devservices-1.0.18 → devservices-1.1.0}/tests/__init__.py +0 -0
  51. {devservices-1.0.18 → devservices-1.1.0}/tests/commands/test_down.py +0 -0
  52. {devservices-1.0.18 → devservices-1.1.0}/tests/commands/test_list_dependencies.py +0 -0
  53. {devservices-1.0.18 → devservices-1.1.0}/tests/commands/test_list_services.py +0 -0
  54. {devservices-1.0.18 → devservices-1.1.0}/tests/commands/test_logs.py +0 -0
  55. {devservices-1.0.18 → devservices-1.1.0}/tests/commands/test_purge.py +0 -0
  56. {devservices-1.0.18 → devservices-1.1.0}/tests/commands/test_update.py +0 -0
  57. {devservices-1.0.18 → devservices-1.1.0}/tests/configs/test_service_config.py +0 -0
  58. {devservices-1.0.18 → devservices-1.1.0}/tests/conftest.py +0 -0
  59. {devservices-1.0.18 → devservices-1.1.0}/tests/utils/test_check_for_update.py +0 -0
  60. {devservices-1.0.18 → devservices-1.1.0}/tests/utils/test_docker.py +0 -0
  61. {devservices-1.0.18 → devservices-1.1.0}/tests/utils/test_git.py +0 -0
  62. {devservices-1.0.18 → devservices-1.1.0}/tests/utils/test_install_binary.py +0 -0
  63. {devservices-1.0.18 → devservices-1.1.0}/tests/utils/test_services.py +0 -0
  64. {devservices-1.0.18 → devservices-1.1.0}/tests/utils/test_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devservices
3
- Version: 1.0.18
3
+ Version: 1.1.0
4
4
  Requires-Python: >=3.10
5
5
  License-File: LICENSE.md
6
6
  Requires-Dist: pyyaml
@@ -30,7 +30,7 @@ NOTE: service-name is an optional parameter. If not provided, devservices will a
30
30
  The recommended way to install devservices is through a virtualenv in the requirements.txt. Once that is installed and a devservices config file is added, you should be able to run `devservices up` to begin local development.
31
31
 
32
32
  ```
33
- devservices==1.0.18
33
+ devservices==1.1.0
34
34
  ```
35
35
 
36
36
  ### 2. Add devservices config files
@@ -22,6 +22,8 @@ from devservices.exceptions import ServiceNotFoundError
22
22
  from devservices.utils.console import Console
23
23
  from devservices.utils.console import Status
24
24
  from devservices.utils.dependencies import construct_dependency_graph
25
+ from devservices.utils.dependencies import DependencyNode
26
+ from devservices.utils.dependencies import DependencyType
25
27
  from devservices.utils.dependencies import get_non_shared_remote_dependencies
26
28
  from devservices.utils.dependencies import install_and_verify_dependencies
27
29
  from devservices.utils.dependencies import InstalledRemoteDependency
@@ -137,7 +139,9 @@ def down(args: Namespace) -> None:
137
139
  # If no other service depends on the service we are trying to bring down, we can bring it down
138
140
  if dependent_service_name is None:
139
141
  try:
140
- _down(service, remote_dependencies, list(mode_dependencies), status)
142
+ bring_down_service(
143
+ service, remote_dependencies, list(mode_dependencies), status
144
+ )
141
145
  except DockerComposeError as dce:
142
146
  capture_exception(dce, level="info")
143
147
  status.failure(f"Failed to stop {service.name}: {dce.stderr}")
@@ -154,16 +158,7 @@ def down(args: Namespace) -> None:
154
158
  console.success(f"{service.name} stopped")
155
159
 
156
160
 
157
- def _bring_down_dependency(
158
- cmd: DockerComposeCommand, current_env: dict[str, str], status: Status
159
- ) -> subprocess.CompletedProcess[str]:
160
- # TODO: Get rid of these constants, we need a smarter way to determine the containers being brought down
161
- for dependency in cmd.services:
162
- status.info(f"Stopping {dependency}")
163
- return run_cmd(cmd.full_command, current_env)
164
-
165
-
166
- def _down(
161
+ def bring_down_service(
167
162
  service: Service,
168
163
  remote_dependencies: set[InstalledRemoteDependency],
169
164
  mode_dependencies: list[str],
@@ -234,7 +229,19 @@ def _get_dependent_service(
234
229
  )
235
230
  # If the service we are trying to bring down is in the dependency graph of another service,
236
231
  # we should not bring it down
237
- if service.name in dependency_graph.graph:
232
+ if (
233
+ DependencyNode(name=service.name, dependency_type=DependencyType.SERVICE)
234
+ in dependency_graph.graph
235
+ ):
238
236
  return other_started_service
239
237
 
240
238
  return None
239
+
240
+
241
+ def _bring_down_dependency(
242
+ cmd: DockerComposeCommand, current_env: dict[str, str], status: Status
243
+ ) -> subprocess.CompletedProcess[str]:
244
+ # TODO: Get rid of these constants, we need a smarter way to determine the containers being brought down
245
+ for dependency in cmd.services:
246
+ status.info(f"Stopping {dependency}")
247
+ return run_cmd(cmd.full_command, current_env)
@@ -0,0 +1,367 @@
1
+ from __future__ import annotations
2
+
3
+ import concurrent.futures
4
+ import json
5
+ import os
6
+ import subprocess
7
+ from argparse import _SubParsersAction
8
+ from argparse import ArgumentParser
9
+ from argparse import Namespace
10
+ from collections import namedtuple
11
+ from typing import TypedDict
12
+
13
+ from sentry_sdk import capture_exception
14
+
15
+ from devservices.constants import Color
16
+ from devservices.constants import CONFIG_FILE_NAME
17
+ from devservices.constants import DEPENDENCY_CONFIG_VERSION
18
+ from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR
19
+ from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY
20
+ from devservices.constants import DEVSERVICES_DIR_NAME
21
+ from devservices.exceptions import ConfigError
22
+ from devservices.exceptions import ConfigNotFoundError
23
+ from devservices.exceptions import DependencyError
24
+ from devservices.exceptions import DockerComposeError
25
+ from devservices.exceptions import ServiceNotFoundError
26
+ from devservices.utils.console import Console
27
+ from devservices.utils.dependencies import construct_dependency_graph
28
+ from devservices.utils.dependencies import DependencyGraph
29
+ from devservices.utils.dependencies import DependencyNode
30
+ from devservices.utils.dependencies import DependencyType
31
+ from devservices.utils.dependencies import install_and_verify_dependencies
32
+ from devservices.utils.dependencies import InstalledRemoteDependency
33
+ from devservices.utils.docker_compose import get_docker_compose_commands_to_run
34
+ from devservices.utils.docker_compose import run_cmd
35
+ from devservices.utils.services import find_matching_service
36
+ from devservices.utils.services import Service
37
+ from devservices.utils.state import ServiceRuntime
38
+ from devservices.utils.state import State
39
+ from devservices.utils.state import StateTables
40
+
41
+ BASE_INDENTATION = " "
42
+
43
+
44
+ ServiceStatus = namedtuple("ServiceStatus", ["name", "formatted_output"])
45
+
46
+
47
+ class ServiceStatusOutput(TypedDict):
48
+ Service: str
49
+ Name: str
50
+ State: str
51
+ Health: str
52
+ RunningFor: str
53
+ Publishers: list[Ports]
54
+
55
+
56
+ class Ports(TypedDict):
57
+ URL: str
58
+ PublishedPort: int
59
+ TargetPort: int
60
+ Protocol: str
61
+
62
+
63
+ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
64
+ parser = subparsers.add_parser("status", help="View status of a service")
65
+ parser.add_argument(
66
+ "service_name",
67
+ help="Name of the service to view status for",
68
+ nargs="?",
69
+ default=None,
70
+ )
71
+ parser.set_defaults(func=status)
72
+
73
+
74
+ def status(args: Namespace) -> None:
75
+ """Get the status of a specified service."""
76
+ console = Console()
77
+ service_name = args.service_name
78
+ try:
79
+ service = find_matching_service(service_name)
80
+ except ConfigNotFoundError as e:
81
+ capture_exception(e)
82
+ console.failure(
83
+ f"{str(e)}. Please specify a service (i.e. `devservices status sentry`) or run the command from a directory with a devservices configuration."
84
+ )
85
+ exit(1)
86
+ except ConfigError as e:
87
+ capture_exception(e)
88
+ console.failure(str(e))
89
+ exit(1)
90
+ except ServiceNotFoundError as e:
91
+ console.failure(str(e))
92
+ exit(1)
93
+
94
+ state = State()
95
+ starting_services = set(state.get_service_entries(StateTables.STARTING_SERVICES))
96
+ started_services = set(state.get_service_entries(StateTables.STARTED_SERVICES))
97
+ active_services = starting_services.union(started_services)
98
+ if service.name not in active_services:
99
+ console.warning(f"Status unavailable. {service.name} is not running standalone")
100
+ return # Since exit(0) is captured as an internal_error by sentry
101
+
102
+ try:
103
+ status_tree = get_status_for_service(service)
104
+ except DependencyError as de:
105
+ capture_exception(de)
106
+ console.failure(
107
+ f"{str(de)}. If this error persists, try running `devservices purge`"
108
+ )
109
+ exit(1)
110
+ except DockerComposeError as dce:
111
+ capture_exception(dce)
112
+ console.failure(f"Failed to get status for {service.name}: {dce.stderr}")
113
+ exit(1)
114
+ console.info(status_tree)
115
+
116
+
117
+ def get_status_for_service(service: Service) -> str:
118
+ state = State()
119
+
120
+ modes = service.config.modes
121
+
122
+ starting_modes = set(
123
+ state.get_active_modes_for_service(service.name, StateTables.STARTING_SERVICES)
124
+ )
125
+ started_modes = set(
126
+ state.get_active_modes_for_service(service.name, StateTables.STARTED_SERVICES)
127
+ )
128
+ active_modes = starting_modes.union(started_modes)
129
+ mode_dependencies = set()
130
+ for active_mode in active_modes:
131
+ active_mode_dependencies = modes.get(active_mode, [])
132
+ mode_dependencies.update(active_mode_dependencies)
133
+
134
+ remote_dependencies = install_and_verify_dependencies(service)
135
+
136
+ dependency_graph = construct_dependency_graph(service, list(active_modes))
137
+
138
+ status_json_results = get_status_json_results(
139
+ service, remote_dependencies, list(mode_dependencies)
140
+ )
141
+
142
+ docker_compose_service_to_status = parse_docker_compose_status(status_json_results)
143
+ status_tree = generate_service_status_tree(
144
+ service.name, dependency_graph, docker_compose_service_to_status
145
+ )
146
+ return status_tree
147
+
148
+
149
+ def get_status_json_results(
150
+ service: Service,
151
+ remote_dependencies: set[InstalledRemoteDependency],
152
+ mode_dependencies: list[str],
153
+ ) -> list[subprocess.CompletedProcess[str]]:
154
+ relative_local_dependency_directory = os.path.relpath(
155
+ os.path.join(DEVSERVICES_DEPENDENCIES_CACHE_DIR, DEPENDENCY_CONFIG_VERSION),
156
+ service.repo_path,
157
+ )
158
+ service_config_file_path = os.path.join(
159
+ service.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
160
+ )
161
+ # Set the environment variable for the local dependencies directory to be used by docker compose
162
+ current_env = os.environ.copy()
163
+ current_env[
164
+ DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY
165
+ ] = relative_local_dependency_directory
166
+ docker_compose_commands = get_docker_compose_commands_to_run(
167
+ service=service,
168
+ remote_dependencies=list(remote_dependencies),
169
+ current_env=current_env,
170
+ command="ps",
171
+ options=["--format", "json"],
172
+ service_config_file_path=service_config_file_path,
173
+ mode_dependencies=mode_dependencies,
174
+ )
175
+
176
+ cmd_outputs = []
177
+
178
+ with concurrent.futures.ThreadPoolExecutor() as executor:
179
+ futures = [
180
+ executor.submit(run_cmd, cmd.full_command, current_env)
181
+ for cmd in docker_compose_commands
182
+ ]
183
+ for future in concurrent.futures.as_completed(futures):
184
+ cmd_outputs.append(future.result())
185
+
186
+ return cmd_outputs
187
+
188
+
189
+ def generate_service_status_tree(
190
+ service_name: str,
191
+ dependency_graph: DependencyGraph,
192
+ docker_compose_service_to_status: dict[str, ServiceStatusOutput],
193
+ indentation: str = "",
194
+ ) -> str:
195
+ output = []
196
+ state = State()
197
+ services_with_local_runtime = state.get_services_by_runtime(ServiceRuntime.LOCAL)
198
+
199
+ dependencies = dependency_graph.graph[
200
+ DependencyNode(name=service_name, dependency_type=DependencyType.SERVICE)
201
+ ]
202
+
203
+ # Using indentation == "" to check if the service is the root service (hacky, but works) since the root service may not be in the services_with_local_runtime set
204
+ runtime = (
205
+ "local"
206
+ if service_name in services_with_local_runtime or indentation == ""
207
+ else "containerized"
208
+ )
209
+
210
+ output = [
211
+ f"{indentation}{Color.BOLD}{service_name}{Color.RESET}:",
212
+ f"{indentation}{BASE_INDENTATION}Type: service",
213
+ f"{indentation}{BASE_INDENTATION}Runtime: {runtime}",
214
+ ]
215
+
216
+ for dependency in sorted(
217
+ dependencies, key=lambda d: (d.dependency_type.value, d.name)
218
+ ):
219
+ if dependency.name in services_with_local_runtime:
220
+ output.append(
221
+ process_service_with_local_runtime(
222
+ dependency,
223
+ indentation + BASE_INDENTATION,
224
+ )
225
+ )
226
+ else:
227
+ output.append(
228
+ process_service_with_containerized_runtime(
229
+ dependency,
230
+ docker_compose_service_to_status,
231
+ indentation + BASE_INDENTATION,
232
+ dependency_graph,
233
+ )
234
+ )
235
+ return "\n".join(output)
236
+
237
+
238
+ def process_service_with_local_runtime(
239
+ dependency: DependencyNode,
240
+ indentation: str,
241
+ ) -> str:
242
+ output = []
243
+ state = State()
244
+ starting_services = set(state.get_service_entries(StateTables.STARTING_SERVICES))
245
+ started_services = set(state.get_service_entries(StateTables.STARTED_SERVICES))
246
+
247
+ if dependency.name in started_services:
248
+ return handle_started_service(dependency, indentation)
249
+ elif dependency.name in starting_services:
250
+ output.append(f"{indentation}{Color.BOLD}{dependency.name}{Color.RESET}:")
251
+ output.append(f"{indentation}{BASE_INDENTATION}Type: service")
252
+ output.append(f"{indentation}{BASE_INDENTATION}Status: starting")
253
+ output.append(f"{indentation}{BASE_INDENTATION}Runtime: local")
254
+ else:
255
+ output.append(f"{indentation}{Color.BOLD}{dependency.name}{Color.RESET}:")
256
+ output.append(f"{indentation}{BASE_INDENTATION}Type: service")
257
+ output.append(f"{indentation}{BASE_INDENTATION}Status: N/A")
258
+ output.append(f"{indentation}{BASE_INDENTATION}Runtime: local")
259
+ return "\n".join(output)
260
+
261
+
262
+ def process_service_with_containerized_runtime(
263
+ dependency: DependencyNode,
264
+ docker_compose_service_to_status: dict[str, ServiceStatusOutput],
265
+ indentation: str,
266
+ dependency_graph: DependencyGraph,
267
+ ) -> str:
268
+ if len(dependency_graph.graph[dependency]) > 0:
269
+ return generate_service_status_tree(
270
+ dependency.name,
271
+ dependency_graph,
272
+ docker_compose_service_to_status,
273
+ indentation,
274
+ )
275
+ else:
276
+ return generate_service_status_details(
277
+ dependency, docker_compose_service_to_status, indentation
278
+ )
279
+
280
+
281
+ def parse_docker_compose_status(
282
+ status_json_results: list[subprocess.CompletedProcess[str]],
283
+ ) -> dict[str, ServiceStatusOutput]:
284
+ """Parse the JSON output from docker-compose status command."""
285
+ docker_compose_service_to_status: dict[str, ServiceStatusOutput] = {}
286
+ for status_json in status_json_results:
287
+ if not status_json.stdout:
288
+ continue
289
+ docker_compose_service_status_output = status_json.stdout.split("\n")[:-1]
290
+ for docker_compose_service_status in docker_compose_service_status_output:
291
+ docker_compose_service_status_json = json.loads(
292
+ docker_compose_service_status
293
+ )
294
+ compose_service = docker_compose_service_status_json["Service"]
295
+ docker_compose_service_to_status[
296
+ compose_service
297
+ ] = docker_compose_service_status_json
298
+
299
+ return docker_compose_service_to_status
300
+
301
+
302
+ def generate_service_status_details(
303
+ dependency: DependencyNode,
304
+ docker_compose_service_to_status: dict[str, ServiceStatusOutput],
305
+ indentation: str,
306
+ ) -> str:
307
+ output = [f"{indentation}{Color.BOLD}{dependency.name}{Color.RESET}:"]
308
+
309
+ if dependency.name not in docker_compose_service_to_status:
310
+ return "\n".join(
311
+ [
312
+ *output,
313
+ f"{indentation}{BASE_INDENTATION}Type: container",
314
+ f"{indentation}{BASE_INDENTATION}Status: N/A",
315
+ ]
316
+ )
317
+
318
+ service_status = docker_compose_service_to_status[dependency.name]
319
+ details = [
320
+ "Type: container",
321
+ f"Status: {service_status.get('State', 'N/A')}",
322
+ f"Health: {format_health(service_status.get('Health', 'N/A'))}",
323
+ f"Container: {service_status.get('Name', 'N/A')}",
324
+ f"Uptime: {service_status.get('RunningFor', 'N/A')}",
325
+ ]
326
+
327
+ output.extend(f"{indentation}{BASE_INDENTATION}{detail}" for detail in details)
328
+
329
+ if service_ports := service_status.get("Publishers", []):
330
+ output.append(f"{indentation}{BASE_INDENTATION}Ports:")
331
+ for service_port in service_ports:
332
+ output.append(
333
+ f"{indentation}{BASE_INDENTATION}{BASE_INDENTATION}{service_port['URL']}:{service_port['PublishedPort']} -> {service_port['TargetPort']}/{service_port['Protocol']}"
334
+ )
335
+
336
+ return "\n".join(output)
337
+
338
+
339
+ def handle_started_service(dependency: DependencyNode, indentation: str) -> str:
340
+ try:
341
+ service_with_local_runtime = find_matching_service(dependency.name)
342
+ except (ConfigError, ServiceNotFoundError) as e:
343
+ capture_exception(e)
344
+ return "\n".join(
345
+ [
346
+ f"{indentation}{Color.BOLD}{dependency.name}{Color.RESET}:",
347
+ f"{indentation}{BASE_INDENTATION}Type: service",
348
+ f"{indentation}{BASE_INDENTATION}Status: N/A",
349
+ f"{indentation}{BASE_INDENTATION}Runtime: local",
350
+ ]
351
+ )
352
+ service_output = get_status_for_service(service_with_local_runtime)
353
+ return "\n".join(
354
+ [f"{indentation}{line}" for line in service_output.splitlines()],
355
+ )
356
+
357
+
358
+ def format_health(health: str) -> str:
359
+ """Format the health status for display."""
360
+ color = (
361
+ Color.GREEN
362
+ if health.lower() == "healthy"
363
+ else Color.RED
364
+ if health.lower() == "unhealthy"
365
+ else Color.YELLOW
366
+ )
367
+ return f"{color}{health}{Color.RESET}"