Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
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
24 changes: 23 additions & 1 deletion py_bentoctl/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,19 @@ def exec(args):
s.logs_service(args.service, args.follow)


class Status(SubCommand):

@staticmethod
def add_args(sp):
sp.add_argument(
"service", type=str, nargs="?", default=c.SERVICE_LITERAL_ALL, choices=c.DOCKER_COMPOSE_SERVICES_PLUS_ALL,
help="Service to check status of, or 'all' to inspect every service.")

@staticmethod
def exec(args):
s.get_services_status(args.service)


class ComposeConfig(SubCommand):

@staticmethod
Expand Down Expand Up @@ -260,6 +273,13 @@ def exec(args):
fh.init_cbioportal()


class InitGarage(SubCommand):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm


@staticmethod
def exec(args):
oh.init_garage()


class InitAll(SubCommand):

@staticmethod
Expand Down Expand Up @@ -374,6 +394,7 @@ def _add_subparser(arg: str, help_text: str, subcommand: Type[SubCommand], alias

# Feature-specific initialization commands
_add_subparser("init-cbioportal", "Initialize cBioPortal if enabled", InitCBioPortal)
_add_subparser("init-garage", "Initialize Garage object storage with single-node layout", InitGarage)

# Database commands
# - Postgres:
Expand All @@ -395,11 +416,12 @@ def _add_subparser(arg: str, help_text: str, subcommand: Type[SubCommand], alias
_add_subparser("prebuilt", "Switch a service back to prebuilt mode.", Prebuilt, aliases=("pre-built", "prod"))
_add_subparser(
"mode", "See if a service (or which services) are in production/development mode.", Mode,
aliases=("state", "status"))
aliases=("state",))
_add_subparser("pull", "Pull the image for a specific service.", Pull)
_add_subparser("shell", "Run a shell inside an already-running service container.", Shell, aliases=("sh",))
_add_subparser("run-as-shell", "Run a shell inside a stopped service container.", RunShell)
_add_subparser("logs", "Check logs for a service.", Logs)
_add_subparser("status", "Check runtime status of services.", Status)
_add_subparser("compose-config", "Generate Compose config YAML.", ComposeConfig)

p_args = parser.parse_args(args)
Expand Down
168 changes: 166 additions & 2 deletions py_bentoctl/services.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import os
import pathlib
import subprocess
Expand All @@ -20,6 +21,7 @@
"run_as_shell_for_service",
"logs_service",
"compose_config",
"get_services_status",
]

BENTO_SERVICES_DATA_BY_KIND = {
Expand All @@ -32,8 +34,8 @@
"auth",
"authz-db",
"gateway",
"garage",
"katsu-db",
"minio",
"redis",
"reference-db",
)
Expand Down Expand Up @@ -299,7 +301,7 @@ def mode_service(compose_service: str) -> None:
else:
mode += "\t(dev)"

print(f"{compose_service[:18].rjust(18)} ", end="")
print(f"{compose_service[:19].rjust(19)} ", end="")
cprint(mode, colour)


Expand Down Expand Up @@ -407,3 +409,165 @@ def logs_service(compose_service: str, follow: bool) -> None:

def compose_config(services_flag: bool) -> None:
os.execvp(c.COMPOSE[0], (*c.COMPOSE, "config", *(("--services",) if services_flag else ())))


def _parse_health_status(status_text: str) -> Literal["healthy", "starting", "unhealthy", "none"]:
"""
Parse health status from Docker status text.

Examples:
- "Up 5 hours (healthy)" → "healthy"
- "Up 2 minutes (health: starting)" → "starting"
- "Up 10 minutes (unhealthy)" → "unhealthy"
- "Up 1 hour" → "none" (no healthcheck configured)
"""
status_lower = status_text.lower()

if "(healthy)" in status_lower:
return "healthy"
elif "health: starting" in status_lower or "(starting)" in status_lower:
return "starting"
elif "(unhealthy)" in status_lower:
return "unhealthy"
else:
return "none"


def _fetch_all_service_statuses() -> Dict[str, Tuple[bool, str, str]]:
"""
Fetch status for all services in one docker compose ps call.
Returns a dict mapping service name to (is_running, status_text, health_status).
"""
compose_cmd = _get_compose_with_files(dev=c.DEV_MODE)
result = subprocess.run(
(*compose_cmd, "ps", "--format", "json"),
capture_output=True,
text=True,
)

if result.returncode != 0:
err(f" Failed to fetch service statuses: {result.stderr.strip()}")
exit(1)

def _load_entry(raw: str):
try:
return json.loads(raw)
except json.JSONDecodeError:
return None

stdout_str = result.stdout.strip()
entries: list = []

if not stdout_str:
return {}

try:
parsed = json.loads(stdout_str)
except json.JSONDecodeError:
entries = [_load_entry(line) for line in stdout_str.splitlines() if line.strip()]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is another occurrence of this same type of comprehension (albeit formatted differently) on line 530 - the split by newline + if strip. this could maybe be moved into a little generator file-private function helper, or at least both lines should consistently use splitlines

entries = [e for e in entries if e is not None]
else:
if isinstance(parsed, list):
entries = [
_load_entry(entry) if isinstance(entry, str) else entry
for entry in parsed
]
entries = [e for e in entries if e is not None]
elif isinstance(parsed, dict):
entries = [parsed]
else:
err(" Unexpected docker compose status output.")
exit(1)

# Build dict mapping service name to status
status_dict = {}
for entry in entries:
service_name = entry.get("Service") or entry.get("Name", "").split("-")[-1]
if not service_name:
continue

is_running = entry.get("State") == "running"
status_text = entry.get("Status") or entry.get("State") or ""
health_status = _parse_health_status(status_text)
status_dict[service_name] = (is_running, status_text, health_status)

return status_dict


def _print_service_status(service: str, running: bool, status_text: str, health_status: str) -> bool:
"""Print service status with color coding. Returns whether service is running."""
print(f"{service[:19].rjust(19)} ", end="")

# Determine color and prefix based on health status
if not running:
colour = "red"
prefix = "not running"
elif health_status == "unhealthy":
colour = "red"
prefix = "unhealthy"
elif health_status == "starting":
colour = "yellow"
prefix = "starting"
else: # healthy or none
colour = "green"
prefix = "running"

suffix = f" ({status_text})" if status_text else ""
cprint(f"{prefix}{suffix}", colour)
return running


def _get_configured_services() -> list[str]:
"""Get the list of services that are actually configured in the current compose setup."""
result = subprocess.run(
(*_get_compose_with_files(dev=c.DEV_MODE), "config", "--services"),
capture_output=True,
text=True,
)
if result.returncode != 0:
err(f" Failed to get configured services: {result.stderr.strip()}")
exit(1)
services = [s.strip() for s in result.stdout.strip().split('\n') if s.strip()]
return services


def get_services_status(compose_service: str) -> None:
compose_service = translate_service_aliases(compose_service)

all_statuses = _fetch_all_service_statuses()

if compose_service == c.SERVICE_LITERAL_ALL:
configured_services = _get_configured_services()

results = []
for service in sorted(configured_services):
if service in all_statuses:
running, status_text, health_status = all_statuses[service]
is_running = _print_service_status(service, running, status_text, health_status)
results.append(is_running)
else:
# Service is configured but not found in docker ps output
print(f"{service[:19].rjust(19)} ", end="")
cprint("not found (No containers)", "red")
results.append(False)

if all(results):
info("All services appear to be running.")
return
err("One or more services are not running.")
exit(1)

# Single service check
check_service_is_compose(compose_service)

if compose_service in all_statuses:
running, status_text, health_status = all_statuses[compose_service]
is_running = _print_service_status(compose_service, running, status_text, health_status)
if not is_running:
err(f"{compose_service} is not running.")
exit(1)
else:
print(f"{compose_service[:19].rjust(19)} ", end="")
cprint("not found (No containers)", "red")
err(f"{compose_service} is not running.")
exit(1)