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 609cf1a2..dd0bcee8 100644 --- a/py_bentoctl/config.py +++ b/py_bentoctl/config.py @@ -21,6 +21,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/entry.py b/py_bentoctl/entry.py index a465f6f8..427e6666 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 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 @@ -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: @@ -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) diff --git a/py_bentoctl/services.py b/py_bentoctl/services.py index 14af30e9..1b4f015c 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", + "get_services_status", ] BENTO_SERVICES_DATA_BY_KIND = { @@ -32,8 +34,8 @@ "auth", "authz-db", "gateway", + "garage", "katsu-db", - "minio", "redis", "reference-db", ) @@ -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[:c.MAX_SERVICE_CHAR_LEN].rjust(c.MAX_SERVICE_CHAR_LEN)} ", end="") cprint(mode, colour) @@ -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()] + 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[:c.MAX_SERVICE_CHAR_LEN].rjust(c.MAX_SERVICE_CHAR_LEN)} ", 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[:c.MAX_SERVICE_CHAR_LEN].rjust(c.MAX_SERVICE_CHAR_LEN)} ", 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[: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)