From 1ad11ea3d0d04929a9bfe9c9e014cec6473e8c75 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 3 Nov 2026 14:57:05 -0800 Subject: [PATCH 1/5] 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 1dab59b3f056636687099632b140eade1afbef00 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 3 Nov 2026 15:17:20 -0800 Subject: [PATCH 2/5] fix(docker): make optional native dependency fallback explicit Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 6 ++++++ pnpm-workspace.yaml | 7 +++++-- scripts/bootstrap-pnpm.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5bf065da..032ee8520 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,12 @@ pnpm install # Install all workspace dependencies CI installs with `pnpm install --frozen-lockfile`; commit an updated `pnpm-lock.yaml` when you change dependencies. +The workspace intentionally skips the optional `ssh2` and `cpu-features` native +install scripts. Scope uses `ssh2` only transitively through Docker test tooling, +and its supported JavaScript implementation preserves that behavior without a +Python/compiler toolchain. Keep required dependency build scripts allowlisted in +`pnpm-workspace.yaml`; do not replace this narrow policy with `--ignore-scripts`. + ## Local development Local authentication requires your browser to trust a development certificate. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5bcac71d6..425060e02 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -34,11 +34,14 @@ packages: ignoredBuiltDependencies: - '@scarf/scarf' + # ssh2 works without these optional native accelerators. Scope reaches ssh2 + # only through Docker test tooling, so keep clean image installs on its + # supported JavaScript fallback instead of invoking node-gyp. + - cpu-features + - ssh2 onlyBuiltDependencies: - '@vscode/vsce-sign' - - cpu-features - esbuild - keytar - protobufjs - - ssh2 diff --git a/scripts/bootstrap-pnpm.test.ts b/scripts/bootstrap-pnpm.test.ts index f0307618f..ed161a2dc 100644 --- a/scripts/bootstrap-pnpm.test.ts +++ b/scripts/bootstrap-pnpm.test.ts @@ -10,12 +10,16 @@ import { rmSync, writeFileSync, } from "node:fs"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; const script = resolve("scripts/bootstrap-pnpm.sh"); const temporaryDirectories: string[] = []; +const { parse } = createRequire(resolve("packages/shared/package.json"))( + "yaml", +) as typeof import("yaml"); const nodeDockerfiles = [ "apps/api/Dockerfile", "apps/judge/Dockerfile", @@ -40,6 +44,12 @@ interface BootstrapFixture { env: NodeJS.ProcessEnv; } +interface PnpmWorkspaceConfig { + ignoredBuiltDependencies?: string[]; + onlyBuiltDependencies?: string[]; + ignoreDepScripts?: boolean; +} + /** * Creates fake Corepack and npm executables that capture registry configuration. */ @@ -231,6 +241,7 @@ describe("pnpm Docker bootstrap", () => { expect(contents, dockerfile).not.toMatch( /fetch[-_]retr(?:y|ies)|network-concurrency/i, ); + expect(contents, dockerfile).not.toContain("--ignore-scripts"); expect(packageInstallLines.length, dockerfile).toBeGreaterThan(0); for (const line of packageInstallLines) { expect(line, dockerfile).toContain( @@ -239,4 +250,23 @@ describe("pnpm Docker bootstrap", () => { } } }); + + it("uses ssh2's supported JavaScript fallback without suppressing other build scripts", () => { + const workspaceSource = readFileSync("pnpm-workspace.yaml", "utf8"); + const workspace = parse(workspaceSource) as PnpmWorkspaceConfig; + + expect(workspace.ignoredBuiltDependencies).toEqual( + expect.arrayContaining(["cpu-features", "ssh2"]), + ); + expect(workspace.onlyBuiltDependencies).not.toEqual( + expect.arrayContaining(["cpu-features", "ssh2"]), + ); + expect(workspace.onlyBuiltDependencies).toEqual( + expect.arrayContaining(["esbuild", "keytar", "protobufjs"]), + ); + expect(workspace.ignoreDepScripts).not.toBe(true); + expect(readFileSync(".npmrc", "utf8")).not.toMatch( + /^\s*ignore-scripts\s*=\s*true\s*$/m, + ); + }); }); From ba229255aa045dbf562856ee2662863b206df748 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 3 Nov 2026 17:05:09 -0800 Subject: [PATCH 3/5] 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 032ee8520..63b147215 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -107,6 +107,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(); + }); }); From 2acb2c2fe4a933d06fbd42a2c909eefa525dbc93 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 3 Nov 2026 18:42:04 -0800 Subject: [PATCH 4/5] revert(dev): remove endpoint readiness layer Revert 5d78953acb6313bc8294e5d5a1e5371c7619e54c and 48487f8d81fafafc7bb292d8ce40ac8a8707a07a so this branch contains only the optional native dependency layer above PR #1603. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 26 +-- docs/architecture/overview.md | 29 --- scripts/dev-compose.sh | 161 +--------------- scripts/verify-dev-stack.sh | 244 ------------------------ scripts/verify-dev-stack.test.ts | 306 ------------------------------- 5 files changed, 5 insertions(+), 761 deletions(-) delete mode 100755 scripts/verify-dev-stack.sh delete mode 100644 scripts/verify-dev-stack.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63b147215..07800c3d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,28 +96,10 @@ 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. 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. - -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 -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. +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. ### Debug the Docker development API diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index a05ed7492..1858dfba2 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -83,35 +83,6 @@ 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. - -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 fe21c21e8..abf3e53e4 100755 --- a/scripts/dev-compose.sh +++ b/scripts/dev-compose.sh @@ -75,163 +75,4 @@ else set -- "${FILTERED_ARGS[@]}" fi -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 - -# 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() { - 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=$! - -# 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=$! - -# 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}" +exec docker compose "${EXTRA_ARGS[@]}" "$@" diff --git a/scripts/verify-dev-stack.sh b/scripts/verify-dev-stack.sh deleted file mode 100755 index 52321e426..000000000 --- a/scripts/verify-dev-stack.sh +++ /dev/null @@ -1,244 +0,0 @@ -#!/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 deleted file mode 100644 index f9dcb1379..000000000 --- a/scripts/verify-dev-stack.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -// 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; - composeCreateAfter?: number; - composeUpStatus?: number; - 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]}" - 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))]}" - 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 - 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 -`); - 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 composeCreatedFile = join(directory, "compose-created"); - 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", - 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", - 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"); - }); - - 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(); - }); -}); From c3ce43c697da8095a6d58819863e61dc539c583b Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 4 Nov 2026 13:22:48 -0800 Subject: [PATCH 5/5] test(docker): guard optional native allowlist entries independently Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- scripts/bootstrap-pnpm.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/bootstrap-pnpm.test.ts b/scripts/bootstrap-pnpm.test.ts index ed161a2dc..c86cd1ad0 100644 --- a/scripts/bootstrap-pnpm.test.ts +++ b/scripts/bootstrap-pnpm.test.ts @@ -258,9 +258,8 @@ describe("pnpm Docker bootstrap", () => { expect(workspace.ignoredBuiltDependencies).toEqual( expect.arrayContaining(["cpu-features", "ssh2"]), ); - expect(workspace.onlyBuiltDependencies).not.toEqual( - expect.arrayContaining(["cpu-features", "ssh2"]), - ); + expect(workspace.onlyBuiltDependencies).not.toContain("cpu-features"); + expect(workspace.onlyBuiltDependencies).not.toContain("ssh2"); expect(workspace.onlyBuiltDependencies).toEqual( expect.arrayContaining(["esbuild", "keytar", "protobufjs"]), );