From 48487f8d81fafafc7bb292d8ce40ac8a8707a07a Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 3 Nov 2026 14:57:05 -0800 Subject: [PATCH 1/2] fix(dev): verify published stack endpoints Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 21 ++- docs/architecture/overview.md | 23 +++ scripts/dev-compose.sh | 116 +++++++++++++- scripts/verify-dev-stack.sh | 244 ++++++++++++++++++++++++++++++ scripts/verify-dev-stack.test.ts | 252 +++++++++++++++++++++++++++++++ 5 files changed, 651 insertions(+), 5 deletions(-) create mode 100755 scripts/verify-dev-stack.sh create mode 100644 scripts/verify-dev-stack.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca11ea73e..d5bf065da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,10 +90,23 @@ pnpm open:portal # Open the portal in your browser The first Docker start compiles the Rust AI gateway inside its development container and can take several minutes. During development, the gateway may use all CPUs assigned to Docker or the Podman VM, while its memory remains limited -to 2 GiB. Keep the Compose command running until the `coder-acp-copilot` logs -show that it connected to MongoDB and ensured `queue-coder-acp-copilot`; runs -submitted before that worker is ready remain pending. Later starts reuse the -gateway's named Cargo target volume and are faster. +to 2 GiB. Later starts reuse the gateway's named Cargo target volume and are +faster. + +After Compose starts application services, `scripts/dev-compose.sh` verifies +their published host endpoints before printing them as usable. The Portal root, +API `/ready`, and gateway `/health/ready` must return a successful response from +the host. The Copilot worker is verified separately: its container must be +running and its logs must confirm both `queue-coder-acp-copilot` creation and +MongoDB connection. The command exits nonzero when these bounded checks expire, +so a Portal page alone is not evidence that runs can process. + +The failure message distinguishes an unhealthy/not-running service, a reachable +but non-ready HTTP endpoint, and a healthy container whose published port is +unreachable from the host. The last case usually points to runtime VM networking +or port forwarding. Use the printed `docker compose ps`, `docker compose port`, +and `docker compose logs` commands to inspect it; the startup script never +selects, restarts, or changes the active Docker runtime or context. ### Debug the Docker development API diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 1858dfba2..9d07fa621 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -83,6 +83,29 @@ flowchart TB ## Data Flow +### Local Compose readiness boundary + +Local Compose startup has two distinct readiness boundaries. Container health +proves that services can respond inside the container network; it does not prove +that a runtime VM published those ports to the developer host. + +For application `up` commands, `scripts/dev-compose.sh` therefore performs +bounded host-side checks against the selected services: + +| Service | Host verification contract | +|---------|----------------------------| +| Portal | Published port serves `/` with HTTP 2xx | +| API | Published port serves `/ready` with HTTP 2xx | +| AI gateway | Published port serves `/health/ready` with HTTP 2xx | +| Copilot worker | Container is running and current logs confirm queue creation plus MongoDB connection | + +Only verified HTTP URLs are printed as usable. Exhausted checks fail the startup +command and distinguish container failure, host transport failure, HTTP +non-readiness, and missing worker startup signals. A healthy-container/failed- +transport result identifies runtime VM networking or port forwarding as the +likely failure class, but the wrapper only prints non-destructive inspection +commands; it never validates or changes the user's active Docker context. + ### User authentication Configured human callers send the **IdP access token unchanged** on each API call. diff --git a/scripts/dev-compose.sh b/scripts/dev-compose.sh index abf3e53e4..0cd894f57 100755 --- a/scripts/dev-compose.sh +++ b/scripts/dev-compose.sh @@ -75,4 +75,118 @@ else set -- "${FILTERED_ARGS[@]}" fi -exec docker compose "${EXTRA_ARGS[@]}" "$@" +COMPOSE_ARGS=("${EXTRA_ARGS[@]}" "$@") + +# Only `up` starts services that can be verified. Preserve direct exec behavior +# for config, build, down, and every other Compose command. +up_index=-1 +for index in "${!COMPOSE_ARGS[@]}"; do + if [ "${COMPOSE_ARGS[$index]}" = "up" ]; then + up_index="$index" + break + fi +done + +if [ "$up_index" -lt 0 ]; then + exec docker compose "${COMPOSE_ARGS[@]}" +fi + +COMPOSE_PREFIX=("${COMPOSE_ARGS[@]:0:$up_index}") +UP_ARGS=("${COMPOSE_ARGS[@]:$((up_index + 1))}") +EXPLICIT_SERVICES=() +skip_next=0 +for arg in "${UP_ARGS[@]}"; do + if [ "$skip_next" -eq 1 ]; then + skip_next=0 + continue + fi + case "$arg" in + --scale|--timeout|--wait-timeout|--pull) + skip_next=1 + ;; + -*) + ;; + *) + EXPLICIT_SERVICES+=("$arg") + ;; + esac +done + +VERIFY_SERVICES="" +if [ "${#EXPLICIT_SERVICES[@]}" -gt 0 ]; then + for service in "${EXPLICIT_SERVICES[@]}"; do + case "$service" in + portal|api|gateway|coder-acp-copilot) + VERIFY_SERVICES+="${VERIFY_SERVICES:+$'\n'}$service" + ;; + esac + done +fi + +# Explicit infra-only starts intentionally skip application readiness checks. +if [ "${#EXPLICIT_SERVICES[@]}" -gt 0 ] && [ -z "$VERIFY_SERVICES" ]; then + exec docker compose "${COMPOSE_ARGS[@]}" +fi + +compose_pid="" +verify_pid="" +cleanup_children() { + local signal="$1" + if [ -n "$verify_pid" ] && kill -0 "$verify_pid" 2>/dev/null; then + kill "-$signal" "$verify_pid" 2>/dev/null || true + fi + if [ -n "$compose_pid" ] && kill -0 "$compose_pid" 2>/dev/null; then + kill "-$signal" "$compose_pid" 2>/dev/null || true + fi +} +trap 'cleanup_children INT; exit 130' INT +trap 'cleanup_children TERM; exit 143' TERM + +docker compose "${COMPOSE_ARGS[@]}" & +compose_pid=$! + +SCOPE_DEV_VERIFY_SERVICES="$VERIFY_SERVICES" \ + "$(dirname "$0")/verify-dev-stack.sh" "${COMPOSE_PREFIX[@]}" & +verify_pid=$! + +# Fail fast if Compose itself exits unsuccessfully during build/startup. A +# successful detached `up -d` is allowed to exit while verification continues. +compose_status="" +while kill -0 "$verify_pid" 2>/dev/null; do + if [ -n "$compose_pid" ] && ! kill -0 "$compose_pid" 2>/dev/null; then + set +e + wait "$compose_pid" + compose_status=$? + set -e + compose_pid="" + if [ "$compose_status" -ne 0 ]; then + kill "$verify_pid" 2>/dev/null || true + wait "$verify_pid" 2>/dev/null || true + exit "$compose_status" + fi + fi + sleep 0.2 +done + +set +e +wait "$verify_pid" +verify_status=$? +set -e +verify_pid="" + +if [ "$verify_status" -ne 0 ]; then + if [ -n "$compose_pid" ] && kill -0 "$compose_pid" 2>/dev/null; then + kill -TERM "$compose_pid" 2>/dev/null || true + wait "$compose_pid" 2>/dev/null || true + fi + exit "$verify_status" +fi + +if [ -n "$compose_pid" ]; then + set +e + wait "$compose_pid" + compose_status=$? + set -e +fi + +exit "${compose_status:-0}" diff --git a/scripts/verify-dev-stack.sh b/scripts/verify-dev-stack.sh new file mode 100755 index 000000000..52321e426 --- /dev/null +++ b/scripts/verify-dev-stack.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +set -euo pipefail + +ATTEMPTS="${SCOPE_DEV_READY_ATTEMPTS:-300}" +DELAY_SECONDS="${SCOPE_DEV_READY_DELAY_SECONDS:-2}" +CURL_TIMEOUT_SECONDS="${SCOPE_DEV_READY_CURL_TIMEOUT_SECONDS:-2}" +REQUESTED_SERVICES="${SCOPE_DEV_VERIFY_SERVICES:-}" + +COMPOSE=(docker compose "$@") +TARGET_SERVICES=(portal api gateway coder-acp-copilot) + +declare -A SERVICE_PORTS=( + [portal]=80 + [api]=80 + [gateway]=18000 +) +declare -A SERVICE_PATHS=( + [portal]=/ + [api]=/ready + [gateway]=/health/ready +) +declare -A SERVICE_LABELS=( + [portal]=Portal + [api]=API + [gateway]=Gateway +) +declare -A LAST_STATES=() +declare -A LAST_CURL_EXIT=() +declare -A LAST_HTTP_CODE=() +declare -A VERIFIED_URLS=() + +WORKER_QUEUE_READY=0 +WORKER_MONGO_READY=0 + +# Resolve the services selected by profiles unless the wrapper supplied an +# explicit service subset from `docker compose up SERVICE...`. +if [ -z "$REQUESTED_SERVICES" ]; then + REQUESTED_SERVICES="$("${COMPOSE[@]}" config --services)" +fi + +ACTIVE_SERVICES=() +for target in "${TARGET_SERVICES[@]}"; do + if grep -qx "$target" <<<"$REQUESTED_SERVICES"; then + ACTIVE_SERVICES+=("$target") + fi +done + +if [ "${#ACTIVE_SERVICES[@]}" -eq 0 ]; then + exit 0 +fi + +# Return the current container state, preferring Docker health when configured. +service_state() { + local service="$1" + local container_id + container_id="$("${COMPOSE[@]}" ps -q "$service" 2>/dev/null | head -n 1)" + + if [ -z "$container_id" ]; then + printf 'not-created' + return + fi + + docker inspect \ + --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \ + "$container_id" 2>/dev/null || printf 'unknown' +} + +# Resolve the published host port without trusting a URL emitted by a service. +published_url() { + local service="$1" + local container_port="$2" + local path="$3" + local mapping + local host_port + + mapping="$("${COMPOSE[@]}" port "$service" "$container_port" 2>/dev/null | head -n 1)" + host_port="${mapping##*:}" + if [[ ! "$host_port" =~ ^[0-9]+$ ]]; then + return 1 + fi + + printf 'http://127.0.0.1:%s%s' "$host_port" "$path" +} + +# Probe once with strict per-attempt bounds. HTTP failures remain distinct from +# transport failures so final diagnostics identify the right failure class. +probe_endpoint() { + local service="$1" + local url="$2" + local result + local curl_exit + + set +e + result="$(curl \ + --silent \ + --output /dev/null \ + --write-out '%{http_code}' \ + --connect-timeout "$CURL_TIMEOUT_SECONDS" \ + --max-time "$CURL_TIMEOUT_SECONDS" \ + "$url" 2>/dev/null)" + curl_exit=$? + set -e + + LAST_CURL_EXIT["$service"]="$curl_exit" + LAST_HTTP_CODE["$service"]="${result:-000}" + + [ "$curl_exit" -eq 0 ] && [[ "$result" =~ ^2[0-9][0-9]$ ]] +} + +# The worker has no HTTP readiness endpoint. Its strongest existing observable +# startup contract is a running container plus both queue and MongoDB startup +# milestones from the current Compose container logs. +probe_worker() { + local state + local logs + + state="$(service_state coder-acp-copilot)" + LAST_STATES[coder-acp-copilot]="$state" + case "$state" in + running|healthy) ;; + *) return 1 ;; + esac + + logs="$("${COMPOSE[@]}" logs --no-color coder-acp-copilot 2>/dev/null || true)" + if grep -Fq '[coder-acp-copilot] Ensured queue exists: queue-coder-acp-copilot' <<<"$logs"; then + WORKER_QUEUE_READY=1 + fi + if grep -Fq '[coder-acp-copilot] Connected to MongoDB' <<<"$logs"; then + WORKER_MONGO_READY=1 + fi + + [ "$WORKER_QUEUE_READY" -eq 1 ] && [ "$WORKER_MONGO_READY" -eq 1 ] +} + +echo "Verifying published development endpoints and worker readiness..." + +all_ready=0 +for ((attempt = 1; attempt <= ATTEMPTS; attempt += 1)); do + all_ready=1 + + for service in "${ACTIVE_SERVICES[@]}"; do + if [ "$service" = "coder-acp-copilot" ]; then + if ! probe_worker; then + all_ready=0 + fi + continue + fi + + state="$(service_state "$service")" + LAST_STATES["$service"]="$state" + case "$state" in + running|healthy) + if url="$(published_url "$service" "${SERVICE_PORTS[$service]}" "${SERVICE_PATHS[$service]}")"; then + if probe_endpoint "$service" "$url"; then + VERIFIED_URLS["$service"]="$url" + else + all_ready=0 + fi + else + LAST_CURL_EXIT["$service"]=1 + LAST_HTTP_CODE["$service"]=000 + all_ready=0 + fi + ;; + *) + all_ready=0 + ;; + esac + done + + if [ "$all_ready" -eq 1 ]; then + break + fi + + if [ "$attempt" -lt "$ATTEMPTS" ]; then + sleep "$DELAY_SECONDS" + fi +done + +if [ "$all_ready" -eq 1 ]; then + for service in portal api gateway; do + if [ -n "${VERIFIED_URLS[$service]:-}" ]; then + safe_url="${VERIFIED_URLS[$service]/127.0.0.1/localhost}" + echo "${SERVICE_LABELS[$service]} verified: $safe_url" + fi + done + if grep -qx 'coder-acp-copilot' <<<"$REQUESTED_SERVICES"; then + echo "Copilot worker verified: container running, queue ensured, and MongoDB connected." + fi + exit 0 +fi + +echo "Development stack readiness verification failed." >&2 +for service in "${ACTIVE_SERVICES[@]}"; do + state="${LAST_STATES[$service]:-not-created}" + + if [ "$service" = "coder-acp-copilot" ]; then + case "$state" in + running|healthy) + missing=() + [ "$WORKER_QUEUE_READY" -eq 0 ] && missing+=("queue creation") + [ "$WORKER_MONGO_READY" -eq 0 ] && missing+=("MongoDB connection") + missing_text="${missing[0]}" + if [ "${#missing[@]}" -gt 1 ]; then + missing_text+=", ${missing[1]}" + fi + printf 'Copilot worker is running but not ready; missing startup signal(s): %s.\n' \ + "$missing_text" >&2 + echo "Inspect with: docker compose logs coder-acp-copilot" >&2 + ;; + *) + echo "Copilot worker is not running and ready (container state: $state)." >&2 + echo "Inspect with: docker compose ps coder-acp-copilot && docker compose logs coder-acp-copilot" >&2 + ;; + esac + continue + fi + + label="${SERVICE_LABELS[$service]}" + if [ -n "${VERIFIED_URLS[$service]:-}" ]; then + continue + fi + case "$state" in + running|healthy) + if [ "${LAST_CURL_EXIT[$service]:-1}" -ne 0 ] || [ "${LAST_HTTP_CODE[$service]:-000}" = "000" ]; then + echo "$label container is $state, but its published host endpoint is unreachable." >&2 + echo "This usually indicates runtime VM networking or host port-forwarding failure." >&2 + echo "Inspect without changing runtimes: docker compose ps $service && docker compose port $service ${SERVICE_PORTS[$service]}" >&2 + else + echo "$label host endpoint is reachable but not ready (HTTP ${LAST_HTTP_CODE[$service]})." >&2 + echo "Inspect with: docker compose logs $service" >&2 + fi + ;; + *) + echo "$label service is unhealthy or not running (container state: $state)." >&2 + echo "Inspect with: docker compose ps $service && docker compose logs $service" >&2 + ;; + esac +done + +exit 1 diff --git a/scripts/verify-dev-stack.test.ts b/scripts/verify-dev-stack.test.ts new file mode 100644 index 000000000..e236adf5a --- /dev/null +++ b/scripts/verify-dev-stack.test.ts @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawnSync } from "node:child_process"; +import { + chmodSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const script = resolve("scripts/verify-dev-stack.sh"); +const wrapper = resolve("scripts/dev-compose.sh"); +const directories: string[] = []; + +interface FixtureOptions { + apiCodes?: string; + gatewayCodes?: string; + portalCodes?: string; + services?: string; + states?: Partial>; + workerReady?: boolean; +} + +function fixture(options: FixtureOptions = {}) { + const directory = mkdtempSync(join(tmpdir(), "scope-verify-stack-")); + directories.push(directory); + const bin = join(directory, "bin"); + const counters = join(directory, "counters"); + mkdirSync(bin); + mkdirSync(counters); + + const docker = join(bin, "docker"); + writeFileSync(docker, `#!/bin/bash +set -eu +if [ "$1" = "inspect" ]; then + container_id="\${@: -1}" + service="\${container_id#container-}" + variable="STATE_\${service//-/_}" + printf '%s\\n' "\${!variable:-running}" + exit 0 +fi +[ "$1" = "compose" ] +shift +arguments=("$@") +command_index=-1 +for index in "\${!arguments[@]}"; do + case "\${arguments[$index]}" in + up|ps|port|logs|config) + command_index="$index" + break + ;; + esac +done +[ "$command_index" -ge 0 ] +command="\${arguments[$command_index]}" +if [ "$command" = "ps" ]; then + last_index=$((\${#arguments[@]} - 1)) + service="\${arguments[$last_index]}" + printf 'container-%s\\n' "$service" +elif [ "$command" = "port" ]; then + service="\${arguments[$((command_index + 1))]}" + case "$service" in + portal) printf '0.0.0.0:5100\\n' ;; + api) printf '0.0.0.0:3100\\n' ;; + gateway) printf '0.0.0.0:18900\\n' ;; + esac +elif [ "$command" = "logs" ]; then + if [ "\${WORKER_READY:-1}" = "1" ]; then + printf '%s\\n' \ + '[coder-acp-copilot] Ensured queue exists: queue-coder-acp-copilot' \ + '[coder-acp-copilot] Connected to MongoDB' + else + printf '%s\\n' '[coder-acp-copilot] Starting worker...' + fi +elif [ "$command" = "config" ]; then + printf '%s\\n' "\${SCOPE_DEV_VERIFY_SERVICES:-}" +elif [ "$command" = "up" ]; then + exit "\${COMPOSE_UP_STATUS:-0}" +else + exit 1 +fi +`); + chmodSync(docker, 0o755); + + const curl = join(bin, "curl"); + writeFileSync(curl, `#!/bin/bash +set -eu +url="\${@: -1}" +case "$url" in + *:5100/*) service=PORTAL ;; + *:3100/*) service=API ;; + *:18900/*) service=GATEWAY ;; + *) exit 2 ;; +esac +sequence_variable="\${service}_CODES" +sequence="\${!sequence_variable:-200}" +counter_file="$COUNTERS/$service" +count=0 +[ -f "$counter_file" ] && count="$(cat "$counter_file")" +count=$((count + 1)) +printf '%s' "$count" > "$counter_file" +code="$(printf '%s' "$sequence" | cut -d, -f"$count")" +[ -n "$code" ] || code="$(printf '%s' "$sequence" | awk -F, '{print $NF}')" +printf '%s' "$code" +[ "$code" != "000" ] || exit 7 +`); + chmodSync(curl, 0o755); + + const sleep = join(bin, "sleep"); + writeFileSync(sleep, "#!/bin/sh\nexit 0\n"); + chmodSync(sleep, 0o755); + + const services = options.services ?? "portal\napi\ngateway\ncoder-acp-copilot"; + const states = options.states ?? {}; + const env: NodeJS.ProcessEnv = { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ""}`, + COUNTERS: counters, + SCOPE_DEV_READY_ATTEMPTS: "3", + SCOPE_DEV_READY_DELAY_SECONDS: "0", + SCOPE_DEV_READY_CURL_TIMEOUT_SECONDS: "1", + SCOPE_DEV_VERIFY_SERVICES: services, + PORTAL_CODES: options.portalCodes ?? "200", + API_CODES: options.apiCodes ?? "200", + GATEWAY_CODES: options.gatewayCodes ?? "200", + WORKER_READY: options.workerReady === false ? "0" : "1", + STATE_portal: states.portal ?? "running", + STATE_api: states.api ?? "healthy", + STATE_gateway: states.gateway ?? "healthy", + STATE_coder_acp_copilot: states["coder-acp-copilot"] ?? "running", + }; + + return { + counters, + run: (overrides: NodeJS.ProcessEnv = {}) => + spawnSync("bash", [script], { + encoding: "utf8", + env: { ...env, ...overrides }, + }), + runWrapper: (overrides: NodeJS.ProcessEnv = {}) => + spawnSync("bash", [wrapper, "up", "api"], { + encoding: "utf8", + env: { ...env, ...overrides }, + }), + }; +} + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("verify-dev-stack.sh", () => { + it("verifies all published endpoints and the Copilot worker", () => { + const result = fixture().run(); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Portal verified: http://localhost:5100/"); + expect(result.stdout).toContain("API verified: http://localhost:3100/ready"); + expect(result.stdout).toContain("Gateway verified: http://localhost:18900/health/ready"); + expect(result.stdout).toContain("queue ensured, and MongoDB connected"); + }); + + it("allows delayed readiness within the bounded attempts", () => { + const result = fixture({ apiCodes: "503,000,200" }).run(); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("API verified"); + }); + + it("reports a reachable endpoint that remains non-ready", () => { + const result = fixture({ apiCodes: "503" }).run(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("API host endpoint is reachable but not ready (HTTP 503)"); + expect(result.stderr).not.toContain("port-forwarding failure"); + }); + + it("reports connection refusal as host unreachability", () => { + const result = fixture({ apiCodes: "000" }).run(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("API container is healthy, but its published host endpoint is unreachable"); + }); + + it("identifies healthy-container host forwarding failures", () => { + const result = fixture({ gatewayCodes: "000" }).run(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("runtime VM networking or host port-forwarding failure"); + expect(result.stderr).toContain("docker compose port gateway 18000"); + }); + + it("reports unhealthy services separately from host endpoint failures", () => { + const result = fixture({ states: { api: "unhealthy" } }).run(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("API service is unhealthy or not running (container state: unhealthy)"); + expect(result.stderr).not.toContain("API container is unhealthy, but its published host endpoint is unreachable"); + }); + + it("requires the worker queue and MongoDB readiness signals", () => { + const result = fixture({ workerReady: false }).run(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Copilot worker is running but not ready"); + expect(result.stderr).toContain("queue creation, MongoDB connection"); + }); + + it("stops after the configured attempt bound", () => { + const test = fixture({ apiCodes: "000" }); + const result = test.run({ SCOPE_DEV_READY_ATTEMPTS: "2" }); + + expect(result.status).toBe(1); + expect(readFileSync(join(test.counters, "API"), "utf8")).toBe("2"); + }); + + it("does not echo secret-bearing environment values", () => { + const secret = "https://user:super-secret@example.test/path?token=hidden"; + const result = fixture({ apiCodes: "000" }).run({ + GITHUB_TOKEN: "ghp_super_secret", + STORAGE_CONNECTION_STRING: secret, + }); + + expect(`${result.stdout}\n${result.stderr}`).not.toContain("ghp_super_secret"); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("super-secret"); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("token=hidden"); + }); + + it("integrates with the Compose wrapper and preserves a successful startup exit", () => { + const result = fixture({ services: "api" }).runWrapper(); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("API verified: http://localhost:3100/ready"); + }); + + it("makes the Compose startup command fail when verification fails", () => { + const result = fixture({ apiCodes: "000", services: "api" }).runWrapper({ + SCOPE_DEV_READY_ATTEMPTS: "1", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("published host endpoint is unreachable"); + }); +}); From 5d78953acb6313bc8294e5d5a1e5371c7619e54c Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 3 Nov 2026 17:05:09 -0800 Subject: [PATCH 2/2] fix(dev): defer readiness checks until containers exist Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 5 +++ docs/architecture/overview.md | 6 ++++ scripts/dev-compose.sh | 45 +++++++++++++++++++++++++ scripts/verify-dev-stack.test.ts | 56 +++++++++++++++++++++++++++++++- 4 files changed, 111 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5bf065da..d9670ddad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,6 +101,11 @@ running and its logs must confirm both `queue-coder-acp-copilot` creation and MongoDB connection. The command exits nonzero when these bounded checks expire, so a Portal page alone is not evidence that runs can process. +Clean image builds do not consume that bounded readiness window. The wrapper +waits for Compose to create the selected application containers, while still +propagating an immediate nonzero build/start failure, and only then starts the +host endpoint and worker readiness attempts. + The failure message distinguishes an unhealthy/not-running service, a reachable but non-ready HTTP endpoint, and a healthy container whose published port is unreachable from the host. The last case usually points to runtime VM networking diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 9d07fa621..a05ed7492 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -106,6 +106,12 @@ transport result identifies runtime VM networking or port forwarding as the likely failure class, but the wrapper only prints non-destructive inspection commands; it never validates or changes the user's active Docker context. +The bounded readiness budget begins after Compose creates every selected +application container. Image builds remain governed by the Compose process +lifecycle instead of an independent timer: a running build can continue, a +nonzero build/start exit fails immediately, and endpoint retries cannot expire +before host publication is possible. + ### User authentication Configured human callers send the **IdP access token unchanged** on each API call. diff --git a/scripts/dev-compose.sh b/scripts/dev-compose.sh index 0cd894f57..fe21c21e8 100755 --- a/scripts/dev-compose.sh +++ b/scripts/dev-compose.sh @@ -128,6 +128,18 @@ if [ "${#EXPLICIT_SERVICES[@]}" -gt 0 ] && [ -z "$VERIFY_SERVICES" ]; then exec docker compose "${COMPOSE_ARGS[@]}" fi +# Resolve profile-selected application services before startup. This lets the +# wrapper distinguish a legitimate image build (containers do not exist yet) +# from the bounded readiness phase after Compose begins creating containers. +if [ -z "$VERIFY_SERVICES" ]; then + selected_services="$(docker compose "${COMPOSE_PREFIX[@]}" config --services)" + for service in portal api gateway coder-acp-copilot; do + if grep -qx "$service" <<<"$selected_services"; then + VERIFY_SERVICES+="${VERIFY_SERVICES:+$'\n'}$service" + fi + done +fi + compose_pid="" verify_pid="" cleanup_children() { @@ -145,6 +157,39 @@ trap 'cleanup_children TERM; exit 143' TERM docker compose "${COMPOSE_ARGS[@]}" & compose_pid=$! +# Do not consume the endpoint readiness budget while Compose is still building +# images and none of the expected application containers can exist. This gate +# is tied to the Compose lifecycle rather than an arbitrary clean-build timer: +# it ends when every selected container has been created or Compose exits. +while [ -n "$compose_pid" ]; do + all_containers_created=1 + while IFS= read -r service; do + [ -n "$service" ] || continue + if [ -z "$(docker compose "${COMPOSE_PREFIX[@]}" ps -q "$service" 2>/dev/null | head -n 1)" ]; then + all_containers_created=0 + break + fi + done <<<"$VERIFY_SERVICES" + + if [ "$all_containers_created" -eq 1 ]; then + break + fi + + if ! kill -0 "$compose_pid" 2>/dev/null; then + set +e + wait "$compose_pid" + compose_status=$? + set -e + compose_pid="" + if [ "$compose_status" -ne 0 ]; then + exit "$compose_status" + fi + break + fi + + sleep 0.2 +done + SCOPE_DEV_VERIFY_SERVICES="$VERIFY_SERVICES" \ "$(dirname "$0")/verify-dev-stack.sh" "${COMPOSE_PREFIX[@]}" & verify_pid=$! diff --git a/scripts/verify-dev-stack.test.ts b/scripts/verify-dev-stack.test.ts index e236adf5a..f9dcb1379 100644 --- a/scripts/verify-dev-stack.test.ts +++ b/scripts/verify-dev-stack.test.ts @@ -20,6 +20,8 @@ const directories: string[] = []; interface FixtureOptions { apiCodes?: string; + composeCreateAfter?: number; + composeUpStatus?: number; gatewayCodes?: string; portalCodes?: string; services?: string; @@ -62,6 +64,15 @@ command="\${arguments[$command_index]}" if [ "$command" = "ps" ]; then last_index=$((\${#arguments[@]} - 1)) service="\${arguments[$last_index]}" + ps_counter_file="$COUNTERS/PS" + ps_count=0 + [ -f "$ps_counter_file" ] && ps_count="$(cat "$ps_counter_file")" + ps_count=$((ps_count + 1)) + printf '%s' "$ps_count" > "$ps_counter_file" + if [ "$ps_count" -le "\${COMPOSE_CREATE_AFTER:-0}" ]; then + exit 0 + fi + [ -n "\${COMPOSE_CREATED_FILE:-}" ] && : > "$COMPOSE_CREATED_FILE" printf 'container-%s\\n' "$service" elif [ "$command" = "port" ]; then service="\${arguments[$((command_index + 1))]}" @@ -81,7 +92,16 @@ elif [ "$command" = "logs" ]; then elif [ "$command" = "config" ]; then printf '%s\\n' "\${SCOPE_DEV_VERIFY_SERVICES:-}" elif [ "$command" = "up" ]; then - exit "\${COMPOSE_UP_STATUS:-0}" + if [ "\${COMPOSE_UP_STATUS:-0}" -ne 0 ]; then + /bin/sleep 0.05 + exit "$COMPOSE_UP_STATUS" + fi + if [ "\${COMPOSE_CREATE_AFTER:-0}" -gt 0 ]; then + while [ ! -f "$COMPOSE_CREATED_FILE" ]; do + /bin/sleep 0.02 + done + fi + exit 0 else exit 1 fi @@ -118,6 +138,7 @@ printf '%s' "$code" const services = options.services ?? "portal\napi\ngateway\ncoder-acp-copilot"; const states = options.states ?? {}; + const composeCreatedFile = join(directory, "compose-created"); const env: NodeJS.ProcessEnv = { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}`, @@ -128,6 +149,9 @@ printf '%s' "$code" SCOPE_DEV_VERIFY_SERVICES: services, PORTAL_CODES: options.portalCodes ?? "200", API_CODES: options.apiCodes ?? "200", + COMPOSE_CREATED_FILE: composeCreatedFile, + COMPOSE_CREATE_AFTER: String(options.composeCreateAfter ?? 0), + COMPOSE_UP_STATUS: String(options.composeUpStatus ?? 0), GATEWAY_CODES: options.gatewayCodes ?? "200", WORKER_READY: options.workerReady === false ? "0" : "1", STATE_portal: states.portal ?? "running", @@ -249,4 +273,34 @@ describe("verify-dev-stack.sh", () => { expect(result.status).toBe(1); expect(result.stderr).toContain("published host endpoint is unreachable"); }); + + it("does not consume readiness attempts while Compose is still building", () => { + const test = fixture({ + composeCreateAfter: 6, + services: "api", + }); + const result = test.runWrapper({ + SCOPE_DEV_READY_ATTEMPTS: "1", + }); + + expect(result.status, result.stderr).toBe(0); + expect(Number(readFileSync(join(test.counters, "PS"), "utf8"))).toBeGreaterThan(6); + expect(readFileSync(join(test.counters, "API"), "utf8")).toBe("1"); + expect(result.stdout).toContain("API verified: http://localhost:3100/ready"); + }); + + it("propagates a Compose build failure without waiting for readiness", () => { + const test = fixture({ + composeCreateAfter: 100, + composeUpStatus: 17, + services: "api", + }); + const result = test.runWrapper({ + SCOPE_DEV_READY_ATTEMPTS: "1", + }); + + expect(result.status).toBe(17); + expect(result.stderr).not.toContain("Development stack readiness verification failed"); + expect(() => readFileSync(join(test.counters, "API"), "utf8")).toThrow(); + }); });