devservices 1.1.6__tar.gz → 1.2.1__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 (70) hide show
  1. {devservices-1.1.6 → devservices-1.2.1}/PKG-INFO +1 -1
  2. {devservices-1.1.6 → devservices-1.2.1}/README.md +3 -1
  3. {devservices-1.1.6 → devservices-1.2.1}/devservices/commands/down.py +54 -1
  4. devservices-1.2.1/devservices/commands/foreground.py +133 -0
  5. {devservices-1.1.6 → devservices-1.2.1}/devservices/commands/list_services.py +9 -1
  6. {devservices-1.1.6 → devservices-1.2.1}/devservices/commands/logs.py +80 -6
  7. {devservices-1.1.6 → devservices-1.2.1}/devservices/commands/reset.py +1 -1
  8. {devservices-1.1.6 → devservices-1.2.1}/devservices/commands/serve.py +17 -9
  9. {devservices-1.1.6 → devservices-1.2.1}/devservices/commands/status.py +93 -7
  10. {devservices-1.1.6 → devservices-1.2.1}/devservices/commands/up.py +185 -60
  11. {devservices-1.1.6 → devservices-1.2.1}/devservices/configs/service_config.py +46 -3
  12. {devservices-1.1.6 → devservices-1.2.1}/devservices/constants.py +8 -1
  13. {devservices-1.1.6 → devservices-1.2.1}/devservices/main.py +2 -0
  14. {devservices-1.1.6 → devservices-1.2.1}/devservices/utils/dependencies.py +2 -10
  15. {devservices-1.1.6 → devservices-1.2.1}/devservices/utils/services.py +3 -3
  16. {devservices-1.1.6 → devservices-1.2.1}/devservices/utils/state.py +2 -1
  17. devservices-1.2.1/devservices/utils/supervisor.py +433 -0
  18. {devservices-1.1.6 → devservices-1.2.1}/devservices.egg-info/PKG-INFO +1 -1
  19. {devservices-1.1.6 → devservices-1.2.1}/devservices.egg-info/SOURCES.txt +2 -0
  20. {devservices-1.1.6 → devservices-1.2.1}/pyproject.toml +1 -1
  21. {devservices-1.1.6 → devservices-1.2.1}/testing/utils.py +0 -9
  22. {devservices-1.1.6 → devservices-1.2.1}/tests/commands/test_down.py +234 -0
  23. devservices-1.2.1/tests/commands/test_foreground.py +665 -0
  24. {devservices-1.1.6 → devservices-1.2.1}/tests/commands/test_list_dependencies.py +7 -2
  25. {devservices-1.1.6 → devservices-1.2.1}/tests/commands/test_list_services.py +44 -0
  26. devservices-1.2.1/tests/commands/test_logs.py +722 -0
  27. {devservices-1.1.6 → devservices-1.2.1}/tests/commands/test_serve.py +14 -19
  28. {devservices-1.1.6 → devservices-1.2.1}/tests/commands/test_status.py +370 -35
  29. {devservices-1.1.6 → devservices-1.2.1}/tests/commands/test_toggle.py +43 -5
  30. {devservices-1.1.6 → devservices-1.2.1}/tests/commands/test_up.py +402 -10
  31. {devservices-1.1.6 → devservices-1.2.1}/tests/configs/test_service_config.py +254 -12
  32. {devservices-1.1.6 → devservices-1.2.1}/tests/utils/test_dependencies.py +37 -1
  33. {devservices-1.1.6 → devservices-1.2.1}/tests/utils/test_services.py +5 -6
  34. {devservices-1.1.6 → devservices-1.2.1}/tests/utils/test_supervisor.py +407 -13
  35. devservices-1.1.6/devservices/utils/supervisor.py +0 -208
  36. devservices-1.1.6/tests/commands/test_logs.py +0 -223
  37. {devservices-1.1.6 → devservices-1.2.1}/LICENSE.md +0 -0
  38. {devservices-1.1.6 → devservices-1.2.1}/devservices/__init__.py +0 -0
  39. {devservices-1.1.6 → devservices-1.2.1}/devservices/commands/__init__.py +0 -0
  40. {devservices-1.1.6 → devservices-1.2.1}/devservices/commands/list_dependencies.py +0 -0
  41. {devservices-1.1.6 → devservices-1.2.1}/devservices/commands/purge.py +0 -0
  42. {devservices-1.1.6 → devservices-1.2.1}/devservices/commands/toggle.py +0 -0
  43. {devservices-1.1.6 → devservices-1.2.1}/devservices/commands/update.py +0 -0
  44. {devservices-1.1.6 → devservices-1.2.1}/devservices/exceptions.py +0 -0
  45. {devservices-1.1.6 → devservices-1.2.1}/devservices/utils/__init__.py +0 -0
  46. {devservices-1.1.6 → devservices-1.2.1}/devservices/utils/check_for_update.py +0 -0
  47. {devservices-1.1.6 → devservices-1.2.1}/devservices/utils/console.py +0 -0
  48. {devservices-1.1.6 → devservices-1.2.1}/devservices/utils/devenv.py +0 -0
  49. {devservices-1.1.6 → devservices-1.2.1}/devservices/utils/docker.py +0 -0
  50. {devservices-1.1.6 → devservices-1.2.1}/devservices/utils/docker_compose.py +0 -0
  51. {devservices-1.1.6 → devservices-1.2.1}/devservices/utils/file_lock.py +0 -0
  52. {devservices-1.1.6 → devservices-1.2.1}/devservices/utils/git.py +0 -0
  53. {devservices-1.1.6 → devservices-1.2.1}/devservices/utils/install_binary.py +0 -0
  54. {devservices-1.1.6 → devservices-1.2.1}/devservices.egg-info/dependency_links.txt +0 -0
  55. {devservices-1.1.6 → devservices-1.2.1}/devservices.egg-info/entry_points.txt +0 -0
  56. {devservices-1.1.6 → devservices-1.2.1}/devservices.egg-info/requires.txt +0 -0
  57. {devservices-1.1.6 → devservices-1.2.1}/devservices.egg-info/top_level.txt +0 -0
  58. {devservices-1.1.6 → devservices-1.2.1}/setup.cfg +0 -0
  59. {devservices-1.1.6 → devservices-1.2.1}/testing/__init__.py +0 -0
  60. {devservices-1.1.6 → devservices-1.2.1}/tests/__init__.py +0 -0
  61. {devservices-1.1.6 → devservices-1.2.1}/tests/commands/test_purge.py +0 -0
  62. {devservices-1.1.6 → devservices-1.2.1}/tests/commands/test_reset.py +0 -0
  63. {devservices-1.1.6 → devservices-1.2.1}/tests/commands/test_update.py +0 -0
  64. {devservices-1.1.6 → devservices-1.2.1}/tests/conftest.py +0 -0
  65. {devservices-1.1.6 → devservices-1.2.1}/tests/utils/test_check_for_update.py +0 -0
  66. {devservices-1.1.6 → devservices-1.2.1}/tests/utils/test_docker.py +0 -0
  67. {devservices-1.1.6 → devservices-1.2.1}/tests/utils/test_docker_compose.py +0 -0
  68. {devservices-1.1.6 → devservices-1.2.1}/tests/utils/test_git.py +0 -0
  69. {devservices-1.1.6 → devservices-1.2.1}/tests/utils/test_install_binary.py +0 -0
  70. {devservices-1.1.6 → devservices-1.2.1}/tests/utils/test_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devservices
3
- Version: 1.1.6
3
+ Version: 1.2.1
4
4
  Requires-Python: >=3.11
5
5
  License-File: LICENSE.md
6
6
  Requires-Dist: pyyaml
@@ -23,6 +23,8 @@ NOTE: service-name is an optional parameter. If not provided, devservices will a
23
23
  - `devservices update` Update devservices to the latest version.
24
24
  - `devservices purge`: Purge the local devservices cache.
25
25
  - `devservices toggle <service-name>`: Toggle the runtime for a service between containerized and local.
26
+ - `devservices serve`: Run a service's devserver.
27
+ - `devservices foreground <process-name>`: Foreground a process that is currently running in the background. This enables interactive debugging.
26
28
 
27
29
  ## Installation
28
30
 
@@ -31,7 +33,7 @@ NOTE: service-name is an optional parameter. If not provided, devservices will a
31
33
  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.
32
34
 
33
35
  ```
34
- devservices==1.1.6
36
+ devservices==1.2.1
35
37
  ```
36
38
 
37
39
  ### 2. Add devservices config files
@@ -11,6 +11,7 @@ from sentry_sdk import capture_exception
11
11
 
12
12
  from devservices.constants import CONFIG_FILE_NAME
13
13
  from devservices.constants import DEPENDENCY_CONFIG_VERSION
14
+ from devservices.constants import DependencyType
14
15
  from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR
15
16
  from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY
16
17
  from devservices.constants import DEVSERVICES_DIR_NAME
@@ -19,11 +20,12 @@ from devservices.exceptions import ConfigNotFoundError
19
20
  from devservices.exceptions import DependencyError
20
21
  from devservices.exceptions import DockerComposeError
21
22
  from devservices.exceptions import ServiceNotFoundError
23
+ from devservices.exceptions import SupervisorConfigError
24
+ from devservices.exceptions import SupervisorError
22
25
  from devservices.utils.console import Console
23
26
  from devservices.utils.console import Status
24
27
  from devservices.utils.dependencies import construct_dependency_graph
25
28
  from devservices.utils.dependencies import DependencyNode
26
- from devservices.utils.dependencies import DependencyType
27
29
  from devservices.utils.dependencies import get_non_shared_remote_dependencies
28
30
  from devservices.utils.dependencies import install_and_verify_dependencies
29
31
  from devservices.utils.dependencies import InstalledRemoteDependency
@@ -35,6 +37,7 @@ from devservices.utils.services import Service
35
37
  from devservices.utils.state import ServiceRuntime
36
38
  from devservices.utils.state import State
37
39
  from devservices.utils.state import StateTables
40
+ from devservices.utils.supervisor import SupervisorManager
38
41
 
39
42
 
40
43
  def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
@@ -105,6 +108,14 @@ def down(args: Namespace) -> None:
105
108
  active_mode_dependencies = modes.get(active_mode, [])
106
109
  mode_dependencies.update(active_mode_dependencies)
107
110
 
111
+ supervisor_programs = [
112
+ dep
113
+ for dep in mode_dependencies
114
+ if dep in service.config.dependencies
115
+ and service.config.dependencies[dep].dependency_type
116
+ == DependencyType.SUPERVISOR
117
+ ]
118
+
108
119
  with Status(
109
120
  lambda: console.warning(f"Stopping {service.name}"),
110
121
  ) as status:
@@ -129,6 +140,13 @@ def down(args: Namespace) -> None:
129
140
  )
130
141
  exit(1)
131
142
 
143
+ try:
144
+ bring_down_supervisor_programs(supervisor_programs, service, status)
145
+ except SupervisorError as se:
146
+ capture_exception(se)
147
+ status.failure(str(se))
148
+ exit(1)
149
+
132
150
  # Check if any service depends on the service we are trying to bring down
133
151
  # TODO: We should also take into account the active modes of the other services (this is not trivial to do)
134
152
  other_started_services = active_services.difference({service.name})
@@ -285,3 +303,38 @@ def _bring_down_dependency(
285
303
  for dependency in cmd.services:
286
304
  status.info(f"Stopping {dependency}")
287
305
  return run_cmd(cmd.full_command, current_env)
306
+
307
+
308
+ def _stop_supervisor_program(
309
+ manager: SupervisorManager, program: str, status: Status
310
+ ) -> None:
311
+ """Stop a single supervisor program."""
312
+ status.info(f"Stopping {program}")
313
+ manager.stop_process(program)
314
+
315
+
316
+ def bring_down_supervisor_programs(
317
+ supervisor_programs: list[str], service: Service, status: Status
318
+ ) -> None:
319
+ if len(supervisor_programs) == 0:
320
+ return
321
+
322
+ config_file_path = os.path.join(
323
+ service.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
324
+ )
325
+
326
+ try:
327
+ manager = SupervisorManager(service.name, config_file_path)
328
+ except SupervisorConfigError:
329
+ raise
330
+
331
+ with concurrent.futures.ThreadPoolExecutor() as executor:
332
+ futures = [
333
+ executor.submit(_stop_supervisor_program, manager, program, status)
334
+ for program in supervisor_programs
335
+ ]
336
+ for future in concurrent.futures.as_completed(futures):
337
+ _ = future.result()
338
+
339
+ status.info("Stopping supervisor daemon")
340
+ manager.stop_supervisor_daemon()
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import pty
5
+ import shlex
6
+ from argparse import _SubParsersAction
7
+ from argparse import ArgumentParser
8
+ from argparse import Namespace
9
+
10
+ from sentry_sdk import capture_exception
11
+
12
+ from devservices.constants import CONFIG_FILE_NAME
13
+ from devservices.constants import DependencyType
14
+ from devservices.constants import DEVSERVICES_DIR_NAME
15
+ from devservices.exceptions import ConfigError
16
+ from devservices.exceptions import ConfigNotFoundError
17
+ from devservices.exceptions import ServiceNotFoundError
18
+ from devservices.exceptions import SupervisorConfigError
19
+ from devservices.exceptions import SupervisorProcessError
20
+ from devservices.utils.console import Console
21
+ from devservices.utils.services import find_matching_service
22
+ from devservices.utils.state import State
23
+ from devservices.utils.state import StateTables
24
+ from devservices.utils.supervisor import SupervisorManager
25
+
26
+
27
+ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
28
+ parser = subparsers.add_parser(
29
+ "foreground", help="Run a service's program in the foreground"
30
+ )
31
+ parser.add_argument(
32
+ "program_name", help="Name of the program to run in the foreground"
33
+ )
34
+ parser.set_defaults(func=foreground)
35
+
36
+
37
+ def foreground(args: Namespace) -> None:
38
+ """Run a service's program in the foreground."""
39
+ console = Console()
40
+ program_name = args.program_name
41
+ try:
42
+ service = find_matching_service()
43
+ except ConfigNotFoundError as e:
44
+ capture_exception(e, level="info")
45
+ console.failure(
46
+ f"{str(e)}. Please specify a service (i.e. `devservices down sentry`) or run the command from a directory with a devservices configuration."
47
+ )
48
+ exit(1)
49
+ except ConfigError as e:
50
+ capture_exception(e)
51
+ console.failure(str(e))
52
+ exit(1)
53
+ except ServiceNotFoundError as e:
54
+ console.failure(str(e))
55
+ exit(1)
56
+ modes = service.config.modes
57
+ if program_name not in service.config.dependencies:
58
+ console.failure(
59
+ f"Program {program_name} does not exist in the service's config"
60
+ )
61
+ return
62
+ state = State()
63
+ starting_services = set(state.get_service_entries(StateTables.STARTING_SERVICES))
64
+ started_services = set(state.get_service_entries(StateTables.STARTED_SERVICES))
65
+ active_services = starting_services.union(started_services)
66
+ if service.name not in active_services:
67
+ console.warning(f"{service.name} is not running")
68
+ return
69
+ active_starting_modes = state.get_active_modes_for_service(
70
+ service.name, StateTables.STARTING_SERVICES
71
+ )
72
+ active_started_modes = state.get_active_modes_for_service(
73
+ service.name, StateTables.STARTED_SERVICES
74
+ )
75
+ active_modes = active_starting_modes or active_started_modes
76
+ mode_dependencies = set()
77
+ for active_mode in active_modes:
78
+ active_mode_dependencies = modes.get(active_mode, [])
79
+ mode_dependencies.update(active_mode_dependencies)
80
+
81
+ supervisor_programs = [
82
+ dep
83
+ for dep in mode_dependencies
84
+ if dep in service.config.dependencies
85
+ and service.config.dependencies[dep].dependency_type
86
+ == DependencyType.SUPERVISOR
87
+ ]
88
+
89
+ if program_name not in supervisor_programs:
90
+ console.failure(
91
+ f"Program {program_name} is not running in any active modes of {service.name}"
92
+ )
93
+ return
94
+
95
+ config_file_path = os.path.join(
96
+ service.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
97
+ )
98
+
99
+ try:
100
+ manager = SupervisorManager(service.name, config_file_path)
101
+ except SupervisorConfigError as e:
102
+ capture_exception(e, level="info")
103
+ return
104
+
105
+ try:
106
+ program_command = manager.get_program_command(program_name)
107
+ except SupervisorConfigError as e:
108
+ capture_exception(e, level="info")
109
+ console.failure(f"Error when getting program command: {str(e)}")
110
+ return
111
+
112
+ try:
113
+ # Stop the supervisor process before running in foreground
114
+ console.info(f"Stopping {program_name} in supervisor")
115
+ manager.stop_process(program_name)
116
+ console.info(f"Starting {program_name} in foreground")
117
+ argv = shlex.split(program_command)
118
+
119
+ # Run the process in foreground
120
+ pty.spawn(argv)
121
+ except SupervisorProcessError as e:
122
+ capture_exception(e)
123
+ console.failure(f"Error stopping {program_name} in supervisor: {str(e)}")
124
+ except (OSError, FileNotFoundError, PermissionError) as e:
125
+ capture_exception(e)
126
+ console.failure(f"Error running {program_name} in foreground: {str(e)}")
127
+
128
+ try:
129
+ console.info(f"Restarting {program_name} in background")
130
+ manager.start_process(program_name)
131
+ except SupervisorProcessError as e:
132
+ capture_exception(e)
133
+ console.failure(f"Error restarting {program_name} in background: {str(e)}")
@@ -4,6 +4,9 @@ from argparse import _SubParsersAction
4
4
  from argparse import ArgumentParser
5
5
  from argparse import Namespace
6
6
 
7
+ from sentry_sdk import capture_exception
8
+
9
+ from devservices.exceptions import ConfigError
7
10
  from devservices.utils.console import Console
8
11
  from devservices.utils.devenv import get_coderoot
9
12
  from devservices.utils.services import get_local_services
@@ -29,7 +32,12 @@ def list_services(args: Namespace) -> None:
29
32
  console = Console()
30
33
  # Get all of the services installed locally
31
34
  coderoot = get_coderoot()
32
- services = get_local_services(coderoot)
35
+ try:
36
+ services = get_local_services(coderoot)
37
+ except ConfigError as e:
38
+ capture_exception(e)
39
+ console.failure(str(e))
40
+ return
33
41
  state = State()
34
42
  starting_services = set(state.get_service_entries(StateTables.STARTING_SERVICES))
35
43
  started_services = set(state.get_service_entries(StateTables.STARTED_SERVICES))
@@ -11,6 +11,7 @@ from sentry_sdk import capture_exception
11
11
 
12
12
  from devservices.constants import CONFIG_FILE_NAME
13
13
  from devservices.constants import DEPENDENCY_CONFIG_VERSION
14
+ from devservices.constants import DependencyType
14
15
  from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR
15
16
  from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY
16
17
  from devservices.constants import DEVSERVICES_DIR_NAME
@@ -20,6 +21,8 @@ from devservices.exceptions import ConfigNotFoundError
20
21
  from devservices.exceptions import DependencyError
21
22
  from devservices.exceptions import DockerComposeError
22
23
  from devservices.exceptions import ServiceNotFoundError
24
+ from devservices.exceptions import SupervisorConfigError
25
+ from devservices.exceptions import SupervisorError
23
26
  from devservices.utils.console import Console
24
27
  from devservices.utils.dependencies import install_and_verify_dependencies
25
28
  from devservices.utils.dependencies import InstalledRemoteDependency
@@ -29,6 +32,7 @@ from devservices.utils.services import find_matching_service
29
32
  from devservices.utils.services import Service
30
33
  from devservices.utils.state import State
31
34
  from devservices.utils.state import StateTables
35
+ from devservices.utils.supervisor import SupervisorManager
32
36
 
33
37
 
34
38
  def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
@@ -61,13 +65,25 @@ def logs(args: Namespace) -> None:
61
65
  except ServiceNotFoundError as e:
62
66
  console.failure(str(e))
63
67
  exit(1)
68
+ state = State()
64
69
 
65
70
  modes = service.config.modes
66
- # TODO: allow custom modes to be used
67
- mode_to_use = "default"
68
- mode_dependencies = modes[mode_to_use]
71
+ starting_modes = set(
72
+ state.get_active_modes_for_service(service.name, StateTables.STARTING_SERVICES)
73
+ )
74
+ started_modes = set(
75
+ state.get_active_modes_for_service(service.name, StateTables.STARTED_SERVICES)
76
+ )
77
+ active_modes = starting_modes.union(started_modes)
78
+ mode_dependencies = set()
79
+ for active_mode in active_modes:
80
+ active_mode_dependencies = modes.get(active_mode, [])
81
+ mode_dependencies.update(active_mode_dependencies)
82
+
83
+ # If no active modes found but service is running, fall back to default mode
84
+ if not mode_dependencies and "default" in modes:
85
+ mode_dependencies.update(modes["default"])
69
86
 
70
- state = State()
71
87
  starting_services = set(state.get_service_entries(StateTables.STARTING_SERVICES))
72
88
  started_services = set(state.get_service_entries(StateTables.STARTED_SERVICES))
73
89
  running_services = starting_services.union(started_services)
@@ -76,7 +92,9 @@ def logs(args: Namespace) -> None:
76
92
  return
77
93
 
78
94
  try:
79
- remote_dependencies = install_and_verify_dependencies(service)
95
+ remote_dependencies = install_and_verify_dependencies(
96
+ service, modes=list(active_modes)
97
+ )
80
98
  except DependencyError as de:
81
99
  capture_exception(de)
82
100
  console.failure(
@@ -84,7 +102,7 @@ def logs(args: Namespace) -> None:
84
102
  )
85
103
  exit(1)
86
104
  try:
87
- logs_output = _logs(service, remote_dependencies, mode_dependencies)
105
+ logs_output = _logs(service, remote_dependencies, list(mode_dependencies))
88
106
  except DockerComposeError as dce:
89
107
  capture_exception(dce, level="info")
90
108
  console.failure(f"Failed to get logs for {service.name}: {dce.stderr}")
@@ -94,6 +112,22 @@ def logs(args: Namespace) -> None:
94
112
  if log_stdout is not None:
95
113
  console.info(log_stdout)
96
114
 
115
+ # Get supervisor program logs
116
+ supervisor_programs = [
117
+ dep
118
+ for dep in mode_dependencies
119
+ if dep in service.config.dependencies
120
+ and service.config.dependencies[dep].dependency_type
121
+ == DependencyType.SUPERVISOR
122
+ ]
123
+
124
+ if len(supervisor_programs) > 0:
125
+ supervisor_logs = _supervisor_logs(service, supervisor_programs)
126
+ for program_name, log_content in supervisor_logs.items():
127
+ if log_content:
128
+ console.info(f"=== Logs for supervisor program: {program_name} ===")
129
+ console.info(log_content)
130
+
97
131
 
98
132
  def _logs(
99
133
  service: Service,
@@ -133,3 +167,43 @@ def _logs(
133
167
  cmd_outputs.append(future.result())
134
168
 
135
169
  return cmd_outputs
170
+
171
+
172
+ def _supervisor_logs(
173
+ service: Service, supervisor_programs: list[str]
174
+ ) -> dict[str, str]:
175
+ if not supervisor_programs:
176
+ return {}
177
+
178
+ supervisor_logs: dict[str, str] = {}
179
+
180
+ config_file_path = os.path.join(
181
+ service.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
182
+ )
183
+ try:
184
+ manager = SupervisorManager(service.name, config_file_path)
185
+ except SupervisorConfigError as e:
186
+ capture_exception(e)
187
+ return supervisor_logs
188
+
189
+ with concurrent.futures.ThreadPoolExecutor() as executor:
190
+ futures = [
191
+ executor.submit(get_program_logs_with_error_handling, manager, program_name)
192
+ for program_name in supervisor_programs
193
+ ]
194
+ for future in concurrent.futures.as_completed(futures):
195
+ program_name, log_content = future.result()
196
+ supervisor_logs[program_name] = log_content
197
+
198
+ return supervisor_logs
199
+
200
+
201
+ def get_program_logs_with_error_handling(
202
+ manager: SupervisorManager, program_name: str
203
+ ) -> tuple[str, str]:
204
+ try:
205
+ log_content = manager.get_program_logs(program_name)
206
+ return program_name, log_content
207
+ except SupervisorError as e:
208
+ capture_exception(e)
209
+ return program_name, f"Error getting logs for {program_name}: {str(e)}"
@@ -7,6 +7,7 @@ from argparse import Namespace
7
7
  from sentry_sdk import capture_exception
8
8
 
9
9
  from devservices.commands.down import down
10
+ from devservices.constants import DependencyType
10
11
  from devservices.constants import DEVSERVICES_ORCHESTRATOR_LABEL
11
12
  from devservices.exceptions import DockerDaemonNotRunningError
12
13
  from devservices.exceptions import DockerError
@@ -14,7 +15,6 @@ from devservices.utils.console import Console
14
15
  from devservices.utils.console import Status
15
16
  from devservices.utils.dependencies import construct_dependency_graph
16
17
  from devservices.utils.dependencies import DependencyNode
17
- from devservices.utils.dependencies import DependencyType
18
18
  from devservices.utils.docker import get_matching_containers
19
19
  from devservices.utils.docker import get_volumes_for_containers
20
20
  from devservices.utils.docker import remove_docker_resources
@@ -9,8 +9,8 @@ from argparse import Namespace
9
9
 
10
10
  from sentry_sdk import capture_exception
11
11
 
12
+ from devservices.constants import CONFIG_FILE_NAME
12
13
  from devservices.constants import DEVSERVICES_DIR_NAME
13
- from devservices.constants import PROGRAMS_CONF_FILE_NAME
14
14
  from devservices.exceptions import ConfigError
15
15
  from devservices.exceptions import ConfigNotFoundError
16
16
  from devservices.exceptions import SupervisorConfigError
@@ -46,16 +46,24 @@ def serve(args: Namespace) -> None:
46
46
  console.failure(str(e))
47
47
  exit(1)
48
48
 
49
- programs_config_path = os.path.join(
50
- service.repo_path, f"{DEVSERVICES_DIR_NAME}/{PROGRAMS_CONF_FILE_NAME}"
49
+ config_file_path = os.path.join(
50
+ service.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
51
51
  )
52
- if not os.path.exists(programs_config_path):
53
- console.failure(f"No programs.conf file found in {programs_config_path}.")
52
+
53
+ try:
54
+ manager = SupervisorManager(service.name, config_file_path)
55
+ except SupervisorConfigError as e:
56
+ capture_exception(e, level="info")
57
+ console.failure(
58
+ f"Unable to bring up devserver due to supervisor config error: {str(e)}"
59
+ )
60
+ return
61
+
62
+ if not manager.has_programs:
63
+ console.failure(
64
+ "No programs found in config. Please add the devserver in the `x-programs` block to your config.yml"
65
+ )
54
66
  return
55
- manager = SupervisorManager(
56
- programs_config_path,
57
- service_name=service.name,
58
- )
59
67
 
60
68
  try:
61
69
  devserver_command = manager.get_program_command("devserver")
@@ -8,6 +8,7 @@ from argparse import _SubParsersAction
8
8
  from argparse import ArgumentParser
9
9
  from argparse import Namespace
10
10
  from collections import namedtuple
11
+ from datetime import timedelta
11
12
  from typing import TypedDict
12
13
 
13
14
  from sentry_sdk import capture_exception
@@ -15,6 +16,7 @@ from sentry_sdk import capture_exception
15
16
  from devservices.constants import Color
16
17
  from devservices.constants import CONFIG_FILE_NAME
17
18
  from devservices.constants import DEPENDENCY_CONFIG_VERSION
19
+ from devservices.constants import DependencyType
18
20
  from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR
19
21
  from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY
20
22
  from devservices.constants import DEVSERVICES_DIR_NAME
@@ -23,11 +25,11 @@ from devservices.exceptions import ConfigNotFoundError
23
25
  from devservices.exceptions import DependencyError
24
26
  from devservices.exceptions import DockerComposeError
25
27
  from devservices.exceptions import ServiceNotFoundError
28
+ from devservices.exceptions import SupervisorConfigError
26
29
  from devservices.utils.console import Console
27
30
  from devservices.utils.dependencies import construct_dependency_graph
28
31
  from devservices.utils.dependencies import DependencyGraph
29
32
  from devservices.utils.dependencies import DependencyNode
30
- from devservices.utils.dependencies import DependencyType
31
33
  from devservices.utils.dependencies import install_and_verify_dependencies
32
34
  from devservices.utils.dependencies import InstalledRemoteDependency
33
35
  from devservices.utils.docker_compose import get_docker_compose_commands_to_run
@@ -37,6 +39,8 @@ from devservices.utils.services import Service
37
39
  from devservices.utils.state import ServiceRuntime
38
40
  from devservices.utils.state import State
39
41
  from devservices.utils.state import StateTables
42
+ from devservices.utils.supervisor import ProcessInfo
43
+ from devservices.utils.supervisor import SupervisorManager
40
44
 
41
45
  BASE_INDENTATION = " "
42
46
 
@@ -99,8 +103,21 @@ def status(args: Namespace) -> None:
99
103
  console.warning(f"Status unavailable. {service.name} is not running standalone")
100
104
  return # Since exit(0) is captured as an internal_error by sentry
101
105
 
106
+ config_file_path = os.path.join(
107
+ service.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
108
+ )
109
+ process_statuses = {}
110
+
111
+ try:
112
+ supervisor_manager = SupervisorManager(service.name, config_file_path)
113
+ process_statuses = supervisor_manager.get_all_process_info()
114
+ except SupervisorConfigError as e:
115
+ capture_exception(e)
116
+ console.failure(str(e))
117
+ exit(1)
118
+
102
119
  try:
103
- status_tree = get_status_for_service(service)
120
+ status_tree = get_status_for_service(service, process_statuses)
104
121
  except DependencyError as de:
105
122
  capture_exception(de)
106
123
  console.failure(
@@ -114,7 +131,9 @@ def status(args: Namespace) -> None:
114
131
  console.info(status_tree)
115
132
 
116
133
 
117
- def get_status_for_service(service: Service) -> str:
134
+ def get_status_for_service(
135
+ service: Service, process_statuses: dict[str, ProcessInfo]
136
+ ) -> str:
118
137
  state = State()
119
138
 
120
139
  modes = service.config.modes
@@ -141,7 +160,10 @@ def get_status_for_service(service: Service) -> str:
141
160
 
142
161
  docker_compose_service_to_status = parse_docker_compose_status(status_json_results)
143
162
  status_tree = generate_service_status_tree(
144
- service.name, dependency_graph, docker_compose_service_to_status
163
+ service.name,
164
+ process_statuses,
165
+ dependency_graph,
166
+ docker_compose_service_to_status,
145
167
  )
146
168
  return status_tree
147
169
 
@@ -188,6 +210,7 @@ def get_status_json_results(
188
210
 
189
211
  def generate_service_status_tree(
190
212
  service_name: str,
213
+ process_statuses: dict[str, ProcessInfo],
191
214
  dependency_graph: DependencyGraph,
192
215
  docker_compose_service_to_status: dict[str, ServiceStatusOutput],
193
216
  indentation: str = "",
@@ -227,6 +250,7 @@ def generate_service_status_tree(
227
250
  output.append(
228
251
  process_service_with_containerized_runtime(
229
252
  dependency,
253
+ process_statuses,
230
254
  docker_compose_service_to_status,
231
255
  indentation + BASE_INDENTATION,
232
256
  dependency_graph,
@@ -261,6 +285,7 @@ def process_service_with_local_runtime(
261
285
 
262
286
  def process_service_with_containerized_runtime(
263
287
  dependency: DependencyNode,
288
+ process_statuses: dict[str, ProcessInfo],
264
289
  docker_compose_service_to_status: dict[str, ServiceStatusOutput],
265
290
  indentation: str,
266
291
  dependency_graph: DependencyGraph,
@@ -268,13 +293,14 @@ def process_service_with_containerized_runtime(
268
293
  if len(dependency_graph.graph[dependency]) > 0:
269
294
  return generate_service_status_tree(
270
295
  dependency.name,
296
+ process_statuses,
271
297
  dependency_graph,
272
298
  docker_compose_service_to_status,
273
299
  indentation,
274
300
  )
275
301
  else:
276
302
  return generate_service_status_details(
277
- dependency, docker_compose_service_to_status, indentation
303
+ dependency, process_statuses, docker_compose_service_to_status, indentation
278
304
  )
279
305
 
280
306
 
@@ -301,16 +327,23 @@ def parse_docker_compose_status(
301
327
 
302
328
  def generate_service_status_details(
303
329
  dependency: DependencyNode,
330
+ process_statuses: dict[str, ProcessInfo],
304
331
  docker_compose_service_to_status: dict[str, ServiceStatusOutput],
305
332
  indentation: str,
306
333
  ) -> str:
307
334
  output = [f"{indentation}{Color.BOLD}{dependency.name}{Color.RESET}:"]
308
335
 
336
+ # Handle supervisor dependencies
337
+ if dependency.dependency_type == DependencyType.SUPERVISOR:
338
+ return generate_supervisor_status_details(
339
+ dependency, process_statuses, indentation
340
+ )
341
+
309
342
  if dependency.name not in docker_compose_service_to_status:
310
343
  return "\n".join(
311
344
  [
312
345
  *output,
313
- f"{indentation}{BASE_INDENTATION}Type: container",
346
+ (f"{indentation}{BASE_INDENTATION}Type: container"),
314
347
  f"{indentation}{BASE_INDENTATION}Status: N/A",
315
348
  ]
316
349
  )
@@ -349,7 +382,7 @@ def handle_started_service(dependency: DependencyNode, indentation: str) -> str:
349
382
  f"{indentation}{BASE_INDENTATION}Runtime: local",
350
383
  ]
351
384
  )
352
- service_output = get_status_for_service(service_with_local_runtime)
385
+ service_output = get_status_for_service(service_with_local_runtime, {})
353
386
  return "\n".join(
354
387
  [f"{indentation}{line}" for line in service_output.splitlines()],
355
388
  )
@@ -365,3 +398,56 @@ def format_health(health: str) -> str:
365
398
  else Color.YELLOW
366
399
  )
367
400
  return f"{color}{health}{Color.RESET}"
401
+
402
+
403
+ def generate_supervisor_status_details(
404
+ dependency: DependencyNode,
405
+ process_statuses: dict[str, ProcessInfo],
406
+ indentation: str,
407
+ ) -> str:
408
+ """Generate status details for supervisor dependencies."""
409
+ output = [f"{indentation}{Color.BOLD}{dependency.name}{Color.RESET}:"]
410
+
411
+ process_info = process_statuses.get(dependency.name)
412
+
413
+ if process_info is None:
414
+ return "\n".join(
415
+ [
416
+ *output,
417
+ f"{indentation}{BASE_INDENTATION}Type: process",
418
+ f"{indentation}{BASE_INDENTATION}Status: N/A (process not found)",
419
+ ]
420
+ )
421
+
422
+ uptime_str = format_uptime(process_info["uptime"])
423
+
424
+ details = [
425
+ "Type: process",
426
+ f"Status: {process_info['state_name'].lower()}",
427
+ f"PID: {process_info['pid'] if process_info['pid'] > 0 else 'N/A'}",
428
+ f"Uptime: {uptime_str}",
429
+ ]
430
+
431
+ output.extend(f"{indentation}{BASE_INDENTATION}{detail}" for detail in details)
432
+
433
+ return "\n".join(output)
434
+
435
+
436
+ def format_uptime(uptime_seconds: int) -> str:
437
+ """Format uptime seconds into a human-readable string."""
438
+ SECONDS_PER_MINUTE = 60
439
+ SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE
440
+
441
+ td = timedelta(seconds=uptime_seconds)
442
+ days = td.days
443
+ hours, remainder = divmod(td.seconds, SECONDS_PER_HOUR)
444
+ minutes, seconds = divmod(remainder, SECONDS_PER_MINUTE)
445
+
446
+ if days > 0:
447
+ return f"{days}d {hours}h {minutes}m {seconds}s"
448
+ elif hours > 0:
449
+ return f"{hours}h {minutes}m {seconds}s"
450
+ elif minutes > 0:
451
+ return f"{minutes}m {seconds}s"
452
+ else:
453
+ return f"{seconds}s"