From af12f8cc6d2166e99f7d57b45e208261e2463ec9 Mon Sep 17 00:00:00 2001 From: SanjeevLakhwani Date: Thu, 4 Dec 2025 14:39:58 -0500 Subject: [PATCH 01/13] feat: add check-services command to verify service runtime status Adds a new `check-services` command to bentoctl that verifies whether Bento services are running and displays their status. Changes: - Add CheckServices subcommand to entry.py - Implement check_services_status() with JSON parsing of docker compose ps - Handle both single services and "all" services check - Display colored output (green=running, red=not running) - Exit with error code if services are not running Also includes: - Add init-garage command for Garage object storage initialization - Update BENTO_USER_EXCLUDED_SERVICES to include garage instead of minio This command is useful for quickly checking the health of the Bento platform without inspecting docker containers directly. --- py_bentoctl/entry.py | 22 +++++++++++ py_bentoctl/services.py | 88 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/py_bentoctl/entry.py b/py_bentoctl/entry.py index a465f6f8..4ae1b8d6 100644 --- a/py_bentoctl/entry.py +++ b/py_bentoctl/entry.py @@ -198,6 +198,19 @@ def exec(args): s.logs_service(args.service, args.follow) +class CheckServices(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, or 'all' to inspect every service.") + + @staticmethod + def exec(args): + s.check_services_status(args.service) + + class ComposeConfig(SubCommand): @staticmethod @@ -260,6 +273,13 @@ def exec(args): fh.init_cbioportal() +class InitGarage(SubCommand): + + @staticmethod + def exec(args): + oh.init_garage() + + class InitAll(SubCommand): @staticmethod @@ -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: @@ -400,6 +421,7 @@ def _add_subparser(arg: str, help_text: str, subcommand: Type[SubCommand], alias _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("check-services", "Verify whether a service is running.", CheckServices) _add_subparser("compose-config", "Generate Compose config YAML.", ComposeConfig) p_args = parser.parse_args(args) diff --git a/py_bentoctl/services.py b/py_bentoctl/services.py index 14af30e9..05467ff1 100644 --- a/py_bentoctl/services.py +++ b/py_bentoctl/services.py @@ -1,3 +1,4 @@ +import json import os import pathlib import subprocess @@ -20,6 +21,7 @@ "run_as_shell_for_service", "logs_service", "compose_config", + "check_services_status", ] BENTO_SERVICES_DATA_BY_KIND = { @@ -32,8 +34,8 @@ "auth", "authz-db", "gateway", + "garage", "katsu-db", - "minio", "redis", "reference-db", ) @@ -407,3 +409,87 @@ 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 _status_compose_args(service: str, service_state: dict) -> Tuple[str, ...]: + if service_state.get(service, {}).get("mode") == MODE_LOCAL: + return _get_compose_with_files(dev=True, local=True) + + return _get_compose_with_files(dev=c.DEV_MODE, local=False) + + +def _get_service_runtime_state(service: str, service_state: dict) -> Tuple[bool, str]: + compose_args = _status_compose_args(service, service_state) + ps = subprocess.run( + (*compose_args, "ps", "--format", "json", service), + capture_output=True, + text=True, + ) + + if ps.returncode != 0: + err(f" Failed to check status for {service}: {ps.stderr.strip()}") + exit(1) + + def _load_entry(raw: str): + try: + return json.loads(raw) + except json.JSONDecodeError: + err(f" Unable to parse docker compose status output for {service}.") + exit(1) + + stdout = (ps.stdout or "").strip() + entries: list = [] + + if not stdout: + entries = [] + else: + try: + parsed = json.loads(stdout) + except json.JSONDecodeError: + entries = [_load_entry(line) for line in stdout.splitlines() if line.strip()] + else: + if isinstance(parsed, list): + entries = [ + _load_entry(entry) if isinstance(entry, str) else entry + for entry in parsed + ] + elif isinstance(parsed, dict): + entries = [parsed] + else: + err(f" Unexpected docker compose status output for {service}.") + exit(1) + + if not entries: + return False, "No containers found" + + 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 + + +def _print_service_runtime_state(service: str, service_state: dict) -> bool: + running, status_text = _get_service_runtime_state(service, service_state) + 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 + + +def check_services_status(compose_service: str) -> None: + compose_service = translate_service_aliases(compose_service) + service_state = get_state()["services"] + + if compose_service == c.SERVICE_LITERAL_ALL: + results = tuple(_print_service_runtime_state(s, service_state) for s in c.DOCKER_COMPOSE_SERVICES) + if all(results): + info("All services appear to be running.") + return + err("One or more services are not running.") + exit(1) + + check_service_is_compose(compose_service) + if not _print_service_runtime_state(compose_service, service_state): + err(f"{compose_service} is not running.") + exit(1) From ac4feb64e73aaa937092f173032b9ca8ad228ab7 Mon Sep 17 00:00:00 2001 From: SanjeevLakhwani Date: Wed, 10 Dec 2025 15:13:31 -0500 Subject: [PATCH 02/13] refactor: rename check-services to status and decouple from mode Renames the check-services command to 'status' for better usability and removes its dependency on the mode system. The status command now uses the base compose configuration to check running containers, making it work independently of whether services are in local or prebuilt mode. Changes: - Rename check-services command to status - Remove 'status' alias from mode command to avoid conflicts - Simplify status checking by removing mode-specific compose args - Status now consistently reports runtime state across all modes --- py_bentoctl/entry.py | 10 +++++----- py_bentoctl/services.py | 26 +++++++++----------------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/py_bentoctl/entry.py b/py_bentoctl/entry.py index 4ae1b8d6..427e6666 100644 --- a/py_bentoctl/entry.py +++ b/py_bentoctl/entry.py @@ -198,17 +198,17 @@ def exec(args): s.logs_service(args.service, args.follow) -class CheckServices(SubCommand): +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, or 'all' to inspect every service.") + help="Service to check status of, or 'all' to inspect every service.") @staticmethod def exec(args): - s.check_services_status(args.service) + s.get_services_status(args.service) class ComposeConfig(SubCommand): @@ -416,12 +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("check-services", "Verify whether a service is running.", CheckServices) + _add_subparser("status", "Check runtime status of services.", Status) _add_subparser("compose-config", "Generate Compose config YAML.", ComposeConfig) p_args = parser.parse_args(args) diff --git a/py_bentoctl/services.py b/py_bentoctl/services.py index 05467ff1..24032021 100644 --- a/py_bentoctl/services.py +++ b/py_bentoctl/services.py @@ -21,7 +21,7 @@ "run_as_shell_for_service", "logs_service", "compose_config", - "check_services_status", + "get_services_status", ] BENTO_SERVICES_DATA_BY_KIND = { @@ -411,17 +411,10 @@ def compose_config(services_flag: bool) -> None: os.execvp(c.COMPOSE[0], (*c.COMPOSE, "config", *(("--services",) if services_flag else ()))) -def _status_compose_args(service: str, service_state: dict) -> Tuple[str, ...]: - if service_state.get(service, {}).get("mode") == MODE_LOCAL: - return _get_compose_with_files(dev=True, local=True) - - return _get_compose_with_files(dev=c.DEV_MODE, local=False) - - -def _get_service_runtime_state(service: str, service_state: dict) -> Tuple[bool, str]: - compose_args = _status_compose_args(service, service_state) +def _get_service_runtime_state(service: str) -> Tuple[bool, str]: + # Use basic compose command to check running containers, independent of mode ps = subprocess.run( - (*compose_args, "ps", "--format", "json", service), + (*c.COMPOSE, "ps", "--format", "json", service), capture_output=True, text=True, ) @@ -467,8 +460,8 @@ def _load_entry(raw: str): return any_running, status_text -def _print_service_runtime_state(service: str, service_state: dict) -> bool: - running, status_text = _get_service_runtime_state(service, service_state) +def _print_service_runtime_state(service: str) -> bool: + running, status_text = _get_service_runtime_state(service) print(f"{service[:18].rjust(18)} ", end="") colour = "green" if running else "red" prefix = "running" if running else "not running" @@ -477,12 +470,11 @@ def _print_service_runtime_state(service: str, service_state: dict) -> bool: return running -def check_services_status(compose_service: str) -> None: +def get_services_status(compose_service: str) -> None: compose_service = translate_service_aliases(compose_service) - service_state = get_state()["services"] if compose_service == c.SERVICE_LITERAL_ALL: - results = tuple(_print_service_runtime_state(s, service_state) for s in c.DOCKER_COMPOSE_SERVICES) + results = tuple(_print_service_runtime_state(s) for s in c.DOCKER_COMPOSE_SERVICES) if all(results): info("All services appear to be running.") return @@ -490,6 +482,6 @@ def check_services_status(compose_service: str) -> None: exit(1) check_service_is_compose(compose_service) - if not _print_service_runtime_state(compose_service, service_state): + if not _print_service_runtime_state(compose_service): err(f"{compose_service} is not running.") exit(1) From 1d0811cad307b2710a0827621c99ea315a7b3683 Mon Sep 17 00:00:00 2001 From: SanjeevLakhwani Date: Mon, 15 Dec 2025 13:10:09 -0500 Subject: [PATCH 03/13] perf: make status checks async for parallel execution Convert status checking to use asyncio for concurrent execution when checking multiple services. This significantly improves performance of 'bentoctl status all' by checking all services in parallel instead of sequentially. Changes: - Add asyncio import - Convert _get_service_runtime_state() to async using create_subprocess_exec - Convert _print_service_runtime_state() to async - Use asyncio.gather() to run all service checks concurrently - Improve error handling to avoid SystemExit in async tasks - Return errors as values instead of calling exit() in async functions - Use return_exceptions=True in gather() for graceful error handling --- py_bentoctl/services.py | 74 ++++++++++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/py_bentoctl/services.py b/py_bentoctl/services.py index 24032021..0a921ae0 100644 --- a/py_bentoctl/services.py +++ b/py_bentoctl/services.py @@ -1,3 +1,4 @@ +import asyncio import json import os import pathlib @@ -411,77 +412,102 @@ def compose_config(services_flag: bool) -> None: os.execvp(c.COMPOSE[0], (*c.COMPOSE, "config", *(("--services",) if services_flag else ()))) -def _get_service_runtime_state(service: str) -> Tuple[bool, str]: +async def _get_service_runtime_state(service: str) -> Tuple[bool, str, Optional[str]]: # Use basic compose command to check running containers, independent of mode - ps = subprocess.run( - (*c.COMPOSE, "ps", "--format", "json", service), - capture_output=True, - text=True, + # 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, ) - if ps.returncode != 0: - err(f" Failed to check status for {service}: {ps.stderr.strip()}") - exit(1) + 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: - err(f" Unable to parse docker compose status output for {service}.") - exit(1) + return None - stdout = (ps.stdout or "").strip() + stdout_str = stdout.decode().strip() entries: list = [] - if not stdout: + if not stdout_str: entries = [] else: try: - parsed = json.loads(stdout) + parsed = json.loads(stdout_str) except json.JSONDecodeError: - entries = [_load_entry(line) for line in stdout.splitlines() if line.strip()] + 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: - err(f" Unexpected docker compose status output for {service}.") - exit(1) + return False, "", f"Unexpected docker compose status output for {service}." if not entries: - return False, "No containers found" + 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 + 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 -def _print_service_runtime_state(service: str) -> bool: - running, status_text = _get_service_runtime_state(service) 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 + return running, None def get_services_status(compose_service: str) -> None: compose_service = translate_service_aliases(compose_service) if compose_service == c.SERVICE_LITERAL_ALL: - results = tuple(_print_service_runtime_state(s) for s in c.DOCKER_COMPOSE_SERVICES) - if all(results): + async def check_all_services(): + tasks = [_print_service_runtime_state(s) for s in c.DOCKER_COMPOSE_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) - if not _print_service_runtime_state(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) From bdc258da9cac0f87fcac4b10d084d6f39a00cd1c Mon Sep 17 00:00:00 2001 From: SanjeevLakhwani Date: Mon, 15 Dec 2025 13:18:43 -0500 Subject: [PATCH 04/13] fix: only check configured services in status command Filter service status checks to only include services that are actually configured in the current compose setup, avoiding false errors for profile-based services like adminer and elasticvue that may not be enabled. Changes: - Add _get_configured_services() to query docker compose config --services - Use configured services list instead of DOCKER_COMPOSE_SERVICES constant - Eliminates errors for services that aren't supposed to be running --- py_bentoctl/services.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/py_bentoctl/services.py b/py_bentoctl/services.py index 0a921ae0..3648b2fd 100644 --- a/py_bentoctl/services.py +++ b/py_bentoctl/services.py @@ -483,12 +483,28 @@ async def _print_service_runtime_state(service: str) -> Tuple[bool, Optional[str 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 c.DOCKER_COMPOSE_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()) From c038dedfa902b8af5e64d9d3ce84568cd2351ac3 Mon Sep 17 00:00:00 2001 From: SanjeevLakhwani Date: Mon, 15 Dec 2025 14:10:27 -0500 Subject: [PATCH 05/13] fix: use consistent compose command for status checks Use the same compose setup (with files and profiles) for both config --services and ps commands. This ensures that all configured services including profile-based and dev services (adminer, elasticvue) can be properly checked. Previously _get_service_runtime_state used basic c.COMPOSE while _get_configured_services used _get_compose_with_files, causing mismatches where services appeared in the config but couldn't be found when checking their status. Changes: - Update _get_service_runtime_state to use _get_compose_with_files - Ensures gohan, beacon, public, auth, adminer, elasticvue are all checked --- py_bentoctl/services.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/py_bentoctl/services.py b/py_bentoctl/services.py index 3648b2fd..53fe54ad 100644 --- a/py_bentoctl/services.py +++ b/py_bentoctl/services.py @@ -413,10 +413,11 @@ def compose_config(services_flag: bool) -> None: async def _get_service_runtime_state(service: str) -> Tuple[bool, str, Optional[str]]: - # Use basic compose command to check running containers, independent of mode + # Use compose command with proper files and profiles to check running containers # Returns (is_running, status_text, error_message) + compose_cmd = _get_compose_with_files(dev=c.DEV_MODE) proc = await asyncio.create_subprocess_exec( - *c.COMPOSE, "ps", "--format", "json", service, + *compose_cmd, "ps", "--format", "json", service, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) @@ -486,14 +487,15 @@ async def _print_service_runtime_state(service: str) -> Tuple[bool, Optional[str 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"), + (*_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) - return [s.strip() for s in result.stdout.strip().split('\n') if s.strip()] + services = [s.strip() for s in result.stdout.strip().split('\n') if s.strip()] + return services def get_services_status(compose_service: str) -> None: From 2d5478319fd9be8881b0ff5c8ac9f2e72342dfb3 Mon Sep 17 00:00:00 2001 From: SanjeevLakhwani Date: Mon, 15 Dec 2025 15:10:44 -0500 Subject: [PATCH 06/13] feat: add health-aware status display with single docker ps call Improve status command to differentiate between different health states using color-coded output, while optimizing performance by fetching all service statuses in a single docker compose ps call. Health status colors: - Green: running and healthy (or no healthcheck) - Yellow: running but health check in progress (health: starting) - Red: not running OR unhealthy Exit codes remain unchanged - only exit 1 if services are not running, not for unhealthy services. Performance improvement: - Previously: N async docker compose ps calls (one per service) - Now: 1 synchronous docker compose ps call for all services - Removed asyncio dependency Changes: - Add _parse_health_status() to extract health status from Docker output - Add _fetch_all_service_statuses() to get all statuses in one call - Add _print_service_status() for color-coded status display - Update get_services_status() to use single ps call approach - Parse "(healthy)", "(health: starting)", "(unhealthy)" from status text - Remove asyncio import and async functions --- py_bentoctl/services.py | 169 +++++++++++++++++++++++++--------------- 1 file changed, 107 insertions(+), 62 deletions(-) diff --git a/py_bentoctl/services.py b/py_bentoctl/services.py index 53fe54ad..b560d55e 100644 --- a/py_bentoctl/services.py +++ b/py_bentoctl/services.py @@ -1,4 +1,3 @@ -import asyncio import json import os import pathlib @@ -412,21 +411,43 @@ 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 compose command with proper files and profiles to check running containers - # Returns (is_running, status_text, error_message) +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) - proc = await asyncio.create_subprocess_exec( - *compose_cmd, "ps", "--format", "json", service, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + result = subprocess.run( + (*compose_cmd, "ps", "--format", "json"), + capture_output=True, + text=True, ) - 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}" + if result.returncode != 0: + err(f" Failed to fetch service statuses: {result.stderr.strip()}") + exit(1) def _load_entry(raw: str): try: @@ -434,54 +455,66 @@ def _load_entry(raw: str): except json.JSONDecodeError: return None - stdout_str = stdout.decode().strip() + stdout_str = result.stdout.strip() entries: list = [] if not stdout_str: - entries = [] + return {} + + 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] else: - try: - parsed = json.loads(stdout_str) - except json.JSONDecodeError: - entries = [_load_entry(line) for line in stdout_str.splitlines() if line.strip()] + 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] - if not entries: - return False, "", f"Unable to parse docker compose status output for {service}." + elif isinstance(parsed, dict): + entries = [parsed] 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}." + err(f" Unexpected docker compose status output.") + exit(1) - if not entries: - return False, "No containers found", None + # 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 - 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 + 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 -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 +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[:18].rjust(18)} ", end="") - colour = "green" if running else "red" - prefix = "running" if running else "not running" + + # 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, None + return running def _get_configured_services() -> list[str]: @@ -501,31 +534,43 @@ def _get_configured_services() -> list[str]: def get_services_status(compose_service: str) -> None: compose_service = translate_service_aliases(compose_service) + # Fetch all service statuses in one docker compose ps call + all_statuses = _fetch_all_service_statuses() + 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) + # Print status for each configured service + results = [] + for service in 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[:18].rjust(18)} ", end="") + cprint("not found (No containers)", "red") + results.append(False) - if all_running and not has_errors: + 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) - running, error_msg = asyncio.run(_print_service_runtime_state(compose_service)) - if error_msg: - err(f" {error_msg}") - exit(1) - if not running: + + 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[:18].rjust(18)} ", end="") + cprint("not found (No containers)", "red") err(f"{compose_service} is not running.") exit(1) From 418a81b866313ad4c102c803a4ca573e1a8bd546 Mon Sep 17 00:00:00 2001 From: SanjeevLakhwani Date: Mon, 15 Dec 2025 15:30:13 -0500 Subject: [PATCH 07/13] feat: alphabetically sort status output Sort services alphabetically when displaying status to make it easier to find specific services in the output, especially when checking all services with 'bentoctl status all'. --- py_bentoctl/services.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/py_bentoctl/services.py b/py_bentoctl/services.py index b560d55e..f335db33 100644 --- a/py_bentoctl/services.py +++ b/py_bentoctl/services.py @@ -541,9 +541,9 @@ def get_services_status(compose_service: str) -> None: # Get only the services that are actually configured configured_services = _get_configured_services() - # Print status for each configured service + # Print status for each configured service in alphabetical order results = [] - for service in configured_services: + 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) From 16e011269d745a7063e60fa8adc8d3c1e9a3ac76 Mon Sep 17 00:00:00 2001 From: SanjeevLakhwani Date: Mon, 15 Dec 2025 15:31:59 -0500 Subject: [PATCH 08/13] fix: remove f-string without placeholders for linting --- py_bentoctl/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/py_bentoctl/services.py b/py_bentoctl/services.py index f335db33..150a1104 100644 --- a/py_bentoctl/services.py +++ b/py_bentoctl/services.py @@ -476,7 +476,7 @@ def _load_entry(raw: str): elif isinstance(parsed, dict): entries = [parsed] else: - err(f" Unexpected docker compose status output.") + err(" Unexpected docker compose status output.") exit(1) # Build dict mapping service name to status From 631987f637676a0466c1d6e9403816f045d2da62 Mon Sep 17 00:00:00 2001 From: SanjeevLakhwani Date: Thu, 18 Dec 2025 14:23:37 -0500 Subject: [PATCH 09/13] fix(bentoctl): increase character print limit --- py_bentoctl/services.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/py_bentoctl/services.py b/py_bentoctl/services.py index 150a1104..80b9727d 100644 --- a/py_bentoctl/services.py +++ b/py_bentoctl/services.py @@ -534,14 +534,11 @@ def _get_configured_services() -> list[str]: def get_services_status(compose_service: str) -> None: compose_service = translate_service_aliases(compose_service) - # Fetch all service statuses in one docker compose ps call all_statuses = _fetch_all_service_statuses() if compose_service == c.SERVICE_LITERAL_ALL: - # Get only the services that are actually configured configured_services = _get_configured_services() - # Print status for each configured service in alphabetical order results = [] for service in sorted(configured_services): if service in all_statuses: From 9619ff5010c00bbe52191f2948d8e8ff141bdf4f Mon Sep 17 00:00:00 2001 From: SanjeevLakhwani Date: Thu, 18 Dec 2025 14:28:08 -0500 Subject: [PATCH 10/13] increase character print limit --- py_bentoctl/services.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/py_bentoctl/services.py b/py_bentoctl/services.py index 80b9727d..0488f2b7 100644 --- a/py_bentoctl/services.py +++ b/py_bentoctl/services.py @@ -301,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) @@ -496,7 +496,7 @@ def _load_entry(raw: str): 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[:18].rjust(18)} ", end="") + print(f"{service[:19].rjust(19)} ", end="") # Determine color and prefix based on health status if not running: @@ -547,7 +547,7 @@ def get_services_status(compose_service: str) -> None: results.append(is_running) else: # Service is configured but not found in docker ps output - print(f"{service[:18].rjust(18)} ", end="") + print(f"{service[:19].rjust(19)} ", end="") cprint("not found (No containers)", "red") results.append(False) @@ -567,7 +567,7 @@ def get_services_status(compose_service: str) -> None: err(f"{compose_service} is not running.") exit(1) else: - print(f"{compose_service[:18].rjust(18)} ", end="") + print(f"{compose_service[:19].rjust(19)} ", end="") cprint("not found (No containers)", "red") err(f"{compose_service} is not running.") exit(1) From e2a1086c2bab53e679b6b9d4f25123156a86d66c Mon Sep 17 00:00:00 2001 From: SanjeevLakhwani Date: Mon, 5 Jan 2026 09:24:39 -0500 Subject: [PATCH 11/13] refactor(bentoctl): Replace 19 with a constant var --- py_bentoctl/config.py | 3 +++ py_bentoctl/services.py | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/py_bentoctl/config.py b/py_bentoctl/config.py index 609cf1a2..712a051b 100644 --- a/py_bentoctl/config.py +++ b/py_bentoctl/config.py @@ -20,6 +20,8 @@ "DOCKER_COMPOSE_SERVICES", "DOCKER_COMPOSE_DEV_SERVICES", + + "MAX_SERVICE_CHAR_LEN", "COMPOSE", "USER", @@ -72,6 +74,7 @@ DEV_MODE = MODE == "dev" SERVICE_LITERAL_ALL: str = "all" +MAX_SERVICE_CHAR_LEN: int = 19 def _env_get_bool(var: str, default: bool = False) -> bool: diff --git a/py_bentoctl/services.py b/py_bentoctl/services.py index 0488f2b7..1b4f015c 100644 --- a/py_bentoctl/services.py +++ b/py_bentoctl/services.py @@ -301,7 +301,7 @@ def mode_service(compose_service: str) -> None: else: mode += "\t(dev)" - print(f"{compose_service[:19].rjust(19)} ", end="") + print(f"{compose_service[:c.MAX_SERVICE_CHAR_LEN].rjust(c.MAX_SERVICE_CHAR_LEN)} ", end="") cprint(mode, colour) @@ -496,7 +496,7 @@ def _load_entry(raw: str): 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="") + print(f"{service[:c.MAX_SERVICE_CHAR_LEN].rjust(c.MAX_SERVICE_CHAR_LEN)} ", end="") # Determine color and prefix based on health status if not running: @@ -547,7 +547,7 @@ def get_services_status(compose_service: str) -> None: results.append(is_running) else: # Service is configured but not found in docker ps output - print(f"{service[:19].rjust(19)} ", end="") + print(f"{service[:c.MAX_SERVICE_CHAR_LEN].rjust(c.MAX_SERVICE_CHAR_LEN)} ", end="") cprint("not found (No containers)", "red") results.append(False) @@ -567,7 +567,7 @@ def get_services_status(compose_service: str) -> None: err(f"{compose_service} is not running.") exit(1) else: - print(f"{compose_service[:19].rjust(19)} ", end="") + print(f"{compose_service[:c.MAX_SERVICE_CHAR_LEN].rjust(c.MAX_SERVICE_CHAR_LEN)} ", end="") cprint("not found (No containers)", "red") err(f"{compose_service} is not running.") exit(1) From 089285c9215be04762f692fbac36e5c17d3d2f1f Mon Sep 17 00:00:00 2001 From: SanjeevLakhwani Date: Mon, 5 Jan 2026 09:26:25 -0500 Subject: [PATCH 12/13] lint: remove whitespace --- lib/garage/config/garage.toml | 18 ++++++++++++++++++ py_bentoctl/config.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 lib/garage/config/garage.toml diff --git a/lib/garage/config/garage.toml b/lib/garage/config/garage.toml new file mode 100644 index 00000000..cc6aa5e1 --- /dev/null +++ b/lib/garage/config/garage.toml @@ -0,0 +1,18 @@ +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" + +# Single-node configuration (v2.x format) +replication_factor = 1 +consistency_mode = "consistent" + +rpc_bind_addr = "[::]:3901" +rpc_public_addr = "127.0.0.1:3901" +rpc_secret = "41f2965d423fe5c3904a76f7c7ab4ba186072fb4d7faf28643ddc8aaa3129e13" + +[s3_api] +s3_region = "garage" +api_bind_addr = "[::]:3900" + +[admin] +api_bind_addr = "[::]:3903" +admin_token = "devgarageadmin789" diff --git a/py_bentoctl/config.py b/py_bentoctl/config.py index 712a051b..337d6c58 100644 --- a/py_bentoctl/config.py +++ b/py_bentoctl/config.py @@ -20,7 +20,7 @@ "DOCKER_COMPOSE_SERVICES", "DOCKER_COMPOSE_DEV_SERVICES", - + "MAX_SERVICE_CHAR_LEN", "COMPOSE", From 4f9f9c01a4631d4558dc5c125ed89988e306e693 Mon Sep 17 00:00:00 2001 From: SanjeevLakhwani Date: Mon, 5 Jan 2026 09:27:56 -0500 Subject: [PATCH 13/13] lint: remove whitespace --- py_bentoctl/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/py_bentoctl/config.py b/py_bentoctl/config.py index 337d6c58..dd0bcee8 100644 --- a/py_bentoctl/config.py +++ b/py_bentoctl/config.py @@ -20,7 +20,7 @@ "DOCKER_COMPOSE_SERVICES", "DOCKER_COMPOSE_DEV_SERVICES", - + "MAX_SERVICE_CHAR_LEN", "COMPOSE",