From ef786aa1d1ce74b428919b939a19563883e4b652 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah Date: Sat, 20 Jun 2026 12:15:15 -0700 Subject: [PATCH] Distinguish optional service error state --- bin/base-demo-services | 44 +++++++++- tests/infra_test.bats | 24 +++++- tests/native_services_test.bats | 24 +++++- tests/services_test.bats | 139 +++++++++++++++++++++++++++++++- 4 files changed, 225 insertions(+), 6 deletions(-) diff --git a/bin/base-demo-services b/bin/base-demo-services index 8032903..69e12c1 100755 --- a/bin/base-demo-services +++ b/bin/base-demo-services @@ -119,6 +119,35 @@ def process_is_running(pid: int | None) -> bool: return True +def compose_state_service_name(service: dict[str, Any]) -> str | None: + service_name = service.get("compose_service") + if isinstance(service_name, str) and service_name: + return service_name + check = service.get("check") or {} + check_service_name = check.get("service") + if check.get("type") == "compose" and isinstance(check_service_name, str) and check_service_name: + return check_service_name + return None + + +def compose_service_has_state(service: dict[str, Any], root: Path) -> bool: + service_name = compose_state_service_name(service) + if service_name is None or shutil.which("docker") is None: + return False + command = compose_command(root, ["ps", "--services", "--all"]) + result = subprocess.run(command, check=False, capture_output=True, text=True) + if result.returncode != 0: + return False + return service_name in set(result.stdout.splitlines()) + + +def service_has_start_state(service: dict[str, Any], root: Path, process_state: dict[str, Any] | None) -> bool: + lifecycle = service.get("lifecycle") or {} + if lifecycle.get("type") == "process": + return process_state is not None + return compose_service_has_state(service, root) + + def compose_command(root: Path, args: list[str]) -> list[str]: return [ "docker", @@ -287,12 +316,17 @@ def health_label(service: dict[str, Any]) -> str: def service_rows(services: list[dict[str, Any]], root: Path) -> list[dict[str, str]]: rows: list[dict[str, str]] = [] for service in services: + process_state = read_process_state(root, service) ok, detail = check_service(service, root) - state = "healthy" if ok else "unhealthy" - if not ok and not service.get("required", False): + if ok: + state = "healthy" + elif service.get("required", False): + state = "unhealthy" + elif service_has_start_state(service, root, process_state): + state = "error" + else: state = "stopped" since = "-" - process_state = read_process_state(root, service) if process_state: started_at = process_state.get("started_at") if isinstance(started_at, str) and started_at: @@ -344,6 +378,10 @@ def cmd_check(services: list[dict[str, Any]], root: Path) -> int: if row["state"] == "healthy": print(f"{row['name']} ok") continue + if row["state"] == "error": + print(f"{row['name']} error {row['detail']}") + failed = True + continue if row["required"] == "true": print(f"{row['name']} fail {row['detail']}") failed = True diff --git a/tests/infra_test.bats b/tests/infra_test.bats index c864aa3..5a492a2 100644 --- a/tests/infra_test.bats +++ b/tests/infra_test.bats @@ -2,6 +2,27 @@ setup() { TEST_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd -P)" + TEST_TMPDIR="$(mktemp -d "${TMPDIR:-/tmp}/base-demo-infra-test.XXXXXX")" +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +write_fake_docker_without_compose_state() { + local bin_dir="$1" + mkdir -p "$bin_dir" + cat > "$bin_dir/docker" <<'EOF' +#!/usr/bin/env bash +if [[ "$*" == *"ps --services --status running"* ]]; then + exit 0 +fi +if [[ "$*" == *"ps --services --all"* ]]; then + exit 0 +fi +exit 0 +EOF + chmod +x "$bin_dir/docker" } @test "compose infrastructure files and catalog entries are present" { @@ -39,7 +60,8 @@ setup() { } @test "services check does not require optional local infrastructure to be running" { - run "$TEST_ROOT/bin/base-demo-services" check + write_fake_docker_without_compose_state "$TEST_TMPDIR/bin" + run env BASE_DEMO_SERVICES_STATE_DIR="$TEST_TMPDIR/state" PATH="$TEST_TMPDIR/bin:$PATH" "$TEST_ROOT/bin/base-demo-services" check [ "$status" -eq 0 ] [[ "$output" == *"project-baseline ok"* ]] diff --git a/tests/native_services_test.bats b/tests/native_services_test.bats index ed04c1c..f8432ab 100644 --- a/tests/native_services_test.bats +++ b/tests/native_services_test.bats @@ -2,6 +2,27 @@ setup() { TEST_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd -P)" + TEST_TMPDIR="$(mktemp -d "${TMPDIR:-/tmp}/base-demo-native-services-test.XXXXXX")" +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +write_fake_docker_without_compose_state() { + local bin_dir="$1" + mkdir -p "$bin_dir" + cat > "$bin_dir/docker" <<'EOF' +#!/usr/bin/env bash +if [[ "$*" == *"ps --services --status running"* ]]; then + exit 0 +fi +if [[ "$*" == *"ps --services --all"* ]]; then + exit 0 +fi +exit 0 +EOF + chmod +x "$bin_dir/docker" } @test "native service files and catalog entries are present" { @@ -58,7 +79,8 @@ setup() { run "$TEST_ROOT/services/cpp-service/test.sh" [ "$status" -eq 0 ] - run "$TEST_ROOT/bin/base-demo-services" check + write_fake_docker_without_compose_state "$TEST_TMPDIR/bin" + run env BASE_DEMO_SERVICES_STATE_DIR="$TEST_TMPDIR/state" PATH="$TEST_TMPDIR/bin:$PATH" "$TEST_ROOT/bin/base-demo-services" check [ "$status" -eq 0 ] [[ "$output" == *"c-service ok"* ]] [[ "$output" == *"cpp-service ok"* ]] diff --git a/tests/services_test.bats b/tests/services_test.bats index 3fdf5ec..905fe01 100644 --- a/tests/services_test.bats +++ b/tests/services_test.bats @@ -9,6 +9,80 @@ teardown() { rm -rf "$TEST_TMPDIR" } +write_optional_file_catalog() { + local catalog="$1" + + cat > "$catalog" < "$catalog" < "$bin_dir/docker" <<'EOF' +#!/usr/bin/env bash +if [[ "$*" == *"ps --services --status running"* ]]; then + exit 0 +fi +if [[ "$*" == *"ps --services --all"* ]]; then + printf 'optional-compose\n' + exit 0 +fi +exit 0 +EOF + chmod +x "$bin_dir/docker" +} + @test "services command is declared and executable" { grep -Fq "services: ./bin/base-demo-services" "$TEST_ROOT/base_manifest.yaml" [ -x "$TEST_ROOT/bin/base-demo-services" ] @@ -27,7 +101,7 @@ teardown() { } @test "services check passes for healthy required entries" { - run "$TEST_ROOT/bin/base-demo-services" check + run env BASE_DEMO_SERVICES_STATE_DIR="$TEST_TMPDIR/state" "$TEST_ROOT/bin/base-demo-services" check [ "$status" -eq 0 ] [[ "$output" == *"project-baseline ok"* ]] @@ -62,3 +136,66 @@ EOF [[ "$output" == *"missing-required fail"* ]] [[ "$output" == *"file:missing.file"* ]] } + +@test "services status keeps never-started optional services stopped" { + local catalog="$TEST_TMPDIR/catalog.json" + write_optional_file_catalog "$catalog" + + run env BASE_DEMO_SERVICES_STATE_DIR="$TEST_TMPDIR/state" "$TEST_ROOT/bin/base-demo-services" --catalog "$catalog" status + + [ "$status" -eq 0 ] + [[ "$output" == *"optional-file"*"stopped"* ]] + [[ "$output" != *"optional-file"*"error"* ]] +} + +@test "services status marks started optional services with failing checks as error" { + local catalog="$TEST_TMPDIR/catalog.json" + local state_dir="$TEST_TMPDIR/state" + write_optional_file_catalog "$catalog" + mkdir -p "$state_dir" + cat > "$state_dir/optional-file.json" < "$state_dir/optional-file.json" <