Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5a82809
add validation for non remote dependencies
hubertdeng123 May 12, 2025
6ecedd5
fix some tests
hubertdeng123 May 13, 2025
eccadb2
add supervisor config validation
hubertdeng123 May 13, 2025
b1e2dc7
add two more tests
hubertdeng123 May 13, 2025
d9c5fc8
change structure of dependeny to also pass in type
hubertdeng123 May 13, 2025
7e4beff
fix more stuff
hubertdeng123 May 13, 2025
eb39a55
remove print statement
hubertdeng123 May 13, 2025
6d22675
fix tests
hubertdeng123 May 13, 2025
03b77d4
actually fix tests
hubertdeng123 May 13, 2025
c92b0c1
fix dependency_type
hubertdeng123 May 14, 2025
0a8c34f
add supervisor program to up
hubertdeng123 May 15, 2025
f64c964
merge main
hubertdeng123 May 21, 2025
879295e
add supervisor to down
hubertdeng123 May 22, 2025
8afa94e
add foreground command
hubertdeng123 May 27, 2025
725af21
uncomment dsn
hubertdeng123 May 27, 2025
4394c3c
add case to not allow bringing up supervisor programs from outside th…
hubertdeng123 May 27, 2025
907e81c
Merge branch 'hubertdeng123/add-supervisor-to-up' into hubertdeng123/…
hubertdeng123 May 27, 2025
f58475d
Merge branch 'hubertdeng123/add-supervisor-to-down' into hubertdeng12…
hubertdeng123 May 27, 2025
25e2b1f
update status to support supervisor programs
hubertdeng123 May 27, 2025
d6c46b5
update the convention
hubertdeng123 May 27, 2025
e3f5c84
update tests
hubertdeng123 May 27, 2025
cc65104
merge main
hubertdeng123 Jun 2, 2025
f9cc289
Merge branch 'hubertdeng123/add-foreground-command' into hubertdeng12…
hubertdeng123 Jun 2, 2025
adf9572
remove description and group info
hubertdeng123 Jun 2, 2025
0a7ed1d
better error handling and naming
hubertdeng123 Jun 3, 2025
8249d2d
merge main
hubertdeng123 Jun 3, 2025
b8b4bc4
Merge branch 'main' into hubertdeng123/update-status-supervisor
hubertdeng123 Jun 4, 2025
fea303f
address last comments
hubertdeng123 Jun 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion devservices/commands/foreground.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ def foreground(args: Namespace) -> None:

# Run the process in foreground
pty.spawn(argv)

except SupervisorProcessError as e:
capture_exception(e)
console.failure(f"Error stopping {program_name} in supervisor: {str(e)}")
Expand Down
96 changes: 90 additions & 6 deletions devservices/commands/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from argparse import ArgumentParser
from argparse import Namespace
from collections import namedtuple
from datetime import timedelta
from typing import TypedDict

from sentry_sdk import capture_exception
Expand All @@ -19,6 +20,7 @@
from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR
from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY
from devservices.constants import DEVSERVICES_DIR_NAME
from devservices.constants import PROGRAMS_CONF_FILE_NAME
from devservices.exceptions import ConfigError
from devservices.exceptions import ConfigNotFoundError
from devservices.exceptions import DependencyError
Expand All @@ -37,6 +39,8 @@
from devservices.utils.state import ServiceRuntime
from devservices.utils.state import State
from devservices.utils.state import StateTables
from devservices.utils.supervisor import ProcessInfo
from devservices.utils.supervisor import SupervisorManager

BASE_INDENTATION = " "

Expand Down Expand Up @@ -99,8 +103,19 @@ def status(args: Namespace) -> None:
console.warning(f"Status unavailable. {service.name} is not running standalone")
return # Since exit(0) is captured as an internal_error by sentry

programs_config_path = os.path.join(
service.repo_path, f"{DEVSERVICES_DIR_NAME}/{PROGRAMS_CONF_FILE_NAME}"
)
process_statuses = {}
if os.path.exists(programs_config_path):
supervisor_manager = SupervisorManager(
programs_config_path,
service.name,
)
process_statuses = supervisor_manager.get_all_process_info()

try:
status_tree = get_status_for_service(service)
status_tree = get_status_for_service(service, process_statuses)
except DependencyError as de:
capture_exception(de)
console.failure(
Expand All @@ -114,7 +129,9 @@ def status(args: Namespace) -> None:
console.info(status_tree)


def get_status_for_service(service: Service) -> str:
def get_status_for_service(
service: Service, process_statuses: dict[str, ProcessInfo]
) -> str:
state = State()

modes = service.config.modes
Expand All @@ -141,7 +158,10 @@ def get_status_for_service(service: Service) -> str:

docker_compose_service_to_status = parse_docker_compose_status(status_json_results)
status_tree = generate_service_status_tree(
service.name, dependency_graph, docker_compose_service_to_status
service.name,
process_statuses,
dependency_graph,
docker_compose_service_to_status,
)
return status_tree

Expand Down Expand Up @@ -188,6 +208,7 @@ def get_status_json_results(

def generate_service_status_tree(
service_name: str,
process_statuses: dict[str, ProcessInfo],
dependency_graph: DependencyGraph,
docker_compose_service_to_status: dict[str, ServiceStatusOutput],
indentation: str = "",
Expand Down Expand Up @@ -227,6 +248,7 @@ def generate_service_status_tree(
output.append(
process_service_with_containerized_runtime(
dependency,
process_statuses,
docker_compose_service_to_status,
indentation + BASE_INDENTATION,
dependency_graph,
Expand Down Expand Up @@ -261,20 +283,22 @@ def process_service_with_local_runtime(

def process_service_with_containerized_runtime(
dependency: DependencyNode,
process_statuses: dict[str, ProcessInfo],
docker_compose_service_to_status: dict[str, ServiceStatusOutput],
indentation: str,
dependency_graph: DependencyGraph,
) -> str:
if len(dependency_graph.graph[dependency]) > 0:
return generate_service_status_tree(
dependency.name,
process_statuses,
dependency_graph,
docker_compose_service_to_status,
indentation,
)
else:
return generate_service_status_details(
dependency, docker_compose_service_to_status, indentation
dependency, process_statuses, docker_compose_service_to_status, indentation
)


Expand All @@ -301,16 +325,23 @@ def parse_docker_compose_status(

def generate_service_status_details(
dependency: DependencyNode,
process_statuses: dict[str, ProcessInfo],
docker_compose_service_to_status: dict[str, ServiceStatusOutput],
indentation: str,
) -> str:
output = [f"{indentation}{Color.BOLD}{dependency.name}{Color.RESET}:"]

# Handle supervisor dependencies
if dependency.dependency_type == DependencyType.SUPERVISOR:
return generate_supervisor_status_details(
dependency, process_statuses, indentation
)

if dependency.name not in docker_compose_service_to_status:
return "\n".join(
[
*output,
f"{indentation}{BASE_INDENTATION}Type: container",
(f"{indentation}{BASE_INDENTATION}Type: container"),
f"{indentation}{BASE_INDENTATION}Status: N/A",
]
)
Expand Down Expand Up @@ -349,7 +380,7 @@ def handle_started_service(dependency: DependencyNode, indentation: str) -> str:
f"{indentation}{BASE_INDENTATION}Runtime: local",
]
)
service_output = get_status_for_service(service_with_local_runtime)
service_output = get_status_for_service(service_with_local_runtime, {})
return "\n".join(
[f"{indentation}{line}" for line in service_output.splitlines()],
)
Expand All @@ -365,3 +396,56 @@ def format_health(health: str) -> str:
else Color.YELLOW
)
return f"{color}{health}{Color.RESET}"


def generate_supervisor_status_details(
dependency: DependencyNode,
process_statuses: dict[str, ProcessInfo],
indentation: str,
) -> str:
"""Generate status details for supervisor dependencies."""
output = [f"{indentation}{Color.BOLD}{dependency.name}{Color.RESET}:"]

process_info = process_statuses.get(dependency.name)

if process_info is None:
return "\n".join(
[
*output,
f"{indentation}{BASE_INDENTATION}Type: process",
f"{indentation}{BASE_INDENTATION}Status: N/A (process not found)",
]
)

uptime_str = format_uptime(process_info["uptime"])

details = [
"Type: process",
f"Status: {process_info['state_name'].lower()}",
f"PID: {process_info['pid'] if process_info['pid'] > 0 else 'N/A'}",
f"Uptime: {uptime_str}",
]

output.extend(f"{indentation}{BASE_INDENTATION}{detail}" for detail in details)

return "\n".join(output)


def format_uptime(uptime_seconds: int) -> str:
"""Format uptime seconds into a human-readable string."""
SECONDS_PER_MINUTE = 60
SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE

td = timedelta(seconds=uptime_seconds)
days = td.days
hours, remainder = divmod(td.seconds, SECONDS_PER_HOUR)
minutes, seconds = divmod(remainder, SECONDS_PER_MINUTE)

if days > 0:
return f"{days}d {hours}h {minutes}m {seconds}s"
elif hours > 0:
return f"{hours}h {minutes}m {seconds}s"
elif minutes > 0:
return f"{minutes}m {seconds}s"
else:
return f"{seconds}s"
72 changes: 72 additions & 0 deletions devservices/utils/supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import time
import xmlrpc.client
from enum import IntEnum
from typing import TypedDict

from sentry_sdk import capture_exception
from supervisor.options import ServerOptions
Expand Down Expand Up @@ -76,6 +77,20 @@
return UnixSocketHTTPConnection(self.socket_path)


class ProcessInfo(TypedDict):
"""Status information for a supervisor process."""

name: str
state: int
state_name: str
description: str
pid: int
uptime: int
start_time: int
stop_time: int
group: str


class SupervisorManager:
def __init__(self, config_file_path: str, service_name: str) -> None:
self.service_name = service_name
Expand Down Expand Up @@ -276,3 +291,60 @@
raise SupervisorError(f"Failed to tail logs for {program_name}: {str(e)}")
except KeyboardInterrupt:
pass

def get_all_process_info(self) -> dict[str, ProcessInfo]:
"""Get status information for all supervisor programs."""
# Check if supervisor client is up first, return empty list if down
try:
client = self._get_rpc_client()
client.supervisor.getState()
except (
xmlrpc.client.Fault,
SupervisorConnectionError,
socket.error,
ConnectionRefusedError,
):
return {}

try:
all_process_info = client.supervisor.getAllProcessInfo()

# Validate that the response is a list before iterating for typechecking
if not isinstance(all_process_info, list):
return {}

Check warning on line 314 in devservices/utils/supervisor.py

View check run for this annotation

Codecov / codecov/patch

devservices/utils/supervisor.py#L314

Added line #L314 was not covered by tests

except xmlrpc.client.Fault as e:
raise SupervisorError(f"Failed to get programs status: {e.faultString}")

process_statuses: dict[str, ProcessInfo] = {}
for process_info in all_process_info:
if not isinstance(process_info, dict):
continue

Check warning on line 322 in devservices/utils/supervisor.py

View check run for this annotation

Codecov / codecov/patch

devservices/utils/supervisor.py#L322

Added line #L322 was not covered by tests

# Extract basic fields with defaults
name = process_info.get("name", "")
state = process_info.get("state", SupervisorProcessState.UNKNOWN)
state_name = SupervisorProcessState(state).name
description = process_info.get("description", "")
pid = process_info.get("pid", 0)
group = process_info.get("group", "")

# Calculate uptime for running processes
start_time = process_info.get("start", 0)
now = process_info.get("now", 0)
uptime = max(0, now - start_time) if start_time > 0 and now > 0 else 0

process_status: ProcessInfo = {
"name": name,
"state": state,
"state_name": state_name,
"description": description,
"pid": pid,
"uptime": uptime,
"start_time": start_time,
"stop_time": process_info.get("stop", 0),
"group": group,
}
process_statuses[name] = process_status

return process_statuses
Loading
Loading