Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
122 changes: 121 additions & 1 deletion py_bentoctl/services.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import asyncio
import json
import os
import pathlib
import subprocess
Expand All @@ -20,6 +22,7 @@
"run_as_shell_for_service",
"logs_service",
"compose_config",
"get_services_status",
]

BENTO_SERVICES_DATA_BY_KIND = {
Expand All @@ -32,8 +35,8 @@
"auth",
"authz-db",
"gateway",
"garage",
"katsu-db",
"minio",
"redis",
"reference-db",
)
Expand Down Expand Up @@ -407,3 +410,120 @@ 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 ())))


async def _get_service_runtime_state(service: str) -> Tuple[bool, str, Optional[str]]:
# Use basic compose command to check running containers, independent of mode
# Returns (is_running, status_text, error_message)
proc = await asyncio.create_subprocess_exec(
*c.COMPOSE, "ps", "--format", "json", service,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)

stdout, stderr = await proc.communicate()

if proc.returncode != 0:
error_msg = stderr.decode().strip()
return False, "", f"Failed to check status for {service}: {error_msg}"

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

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

if not stdout_str:
entries = []
else:
try:
parsed = json.loads(stdout_str)
except json.JSONDecodeError:
entries = [_load_entry(line) for line in stdout_str.splitlines() if line.strip()]
entries = [e for e in entries if e is not None]
if not entries:
return False, "", f"Unable to parse docker compose status output for {service}."
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:
return False, "", f"Unexpected docker compose status output for {service}."

if not entries:
return False, "No containers found", None

any_running = any(e.get("State") == "running" for e in entries)
status_text = entries[0].get("Status") or entries[0].get("State") or ""
return any_running, status_text, None


async def _print_service_runtime_state(service: str) -> Tuple[bool, Optional[str]]:
# Returns (is_running, error_message)
running, status_text, error_msg = await _get_service_runtime_state(service)

if error_msg:
print(f"{service[:18].rjust(18)} ", end="")
cprint(f"error ({error_msg})", "red")
return False, error_msg

print(f"{service[:18].rjust(18)} ", end="")
colour = "green" if running else "red"
prefix = "running" if running else "not running"
suffix = f" ({status_text})" if status_text else ""
cprint(f"{prefix}{suffix}", colour)
return running, None


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


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

if compose_service == c.SERVICE_LITERAL_ALL:
# Get only the services that are actually configured
configured_services = _get_configured_services()

async def check_all_services():
tasks = [_print_service_runtime_state(s) for s in configured_services]
return await asyncio.gather(*tasks, return_exceptions=True)

results = asyncio.run(check_all_services())

# Check if any results are exceptions
has_errors = any(isinstance(r, Exception) or (isinstance(r, tuple) and r[1] is not None) for r in results)
all_running = all(isinstance(r, tuple) and r[0] and r[1] is None for r in results)

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

check_service_is_compose(compose_service)
running, error_msg = asyncio.run(_print_service_runtime_state(compose_service))
if error_msg:
err(f" {error_msg}")
exit(1)
if not running:
err(f"{compose_service} is not running.")
exit(1)