mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
466517ff9b
* fix(docker): drop npm from the standalone and control-plane runtime stages Every published image I scanned ships npm's own bundled copy of tar 6.2.1, which carries one CRITICAL and eight HIGH advisories, all of them fixed upstream: CVE-2026-59873 CRITICAL fixed in 7.5.19 CVE-2026-23745 HIGH fixed in 7.5.3 CVE-2026-23950 HIGH fixed in 7.5.4 CVE-2026-24842 HIGH fixed in 7.5.7 CVE-2026-26960 HIGH fixed in 7.5.8 CVE-2026-29786 HIGH fixed in 7.5.10 CVE-2026-31802 HIGH fixed in 7.5.11 CVE-2026-59874 HIGH fixed in 7.5.18 CVE-2026-73566 HIGH fixed in 7.5.21 It is npm's vendored tar, not an application dependency, so no package.json, lockfile or `overrides` change reaches it: /usr/lib/node_modules/npm/node_modules/tar (standalone) /usr/local/lib/node_modules/npm/node_modules/tar (cp-only) Bumping Node does not clear it either. Node 20 pins npm 10.8.2, which vendors tar 6.2.1; Node 22 pins npm 10.9.8 (tar 7.5.11, still under the CRITICAL fix) and Node 24 pins npm 11.19.0 (tar 7.5.19, still under CVE-2026-73566). Removing npm is what clears all nine findings. npm is never invoked at runtime. Both stages run `/app/start-all.sh`, which starts the API console script and launches the control plane as a pre-built Next standalone bundle with `node server.js` (docker/standalone/start-all.sh:305). No path I could find in the image shells out to a package manager: there is no HEALTHCHECK, no migration step, no plugin install, and no `next` CLI use at runtime. There is also no `corepack enable` anywhere under docker/ and no global npm install in any image stage, so nothing re-adds npm or npx to PATH in the runtime layers. The npm invocations in this file (lines 96, 97, 117, 134) are all in the node:20-slim builder stages, which keep npm untouched. `node` itself stays. Removal is by path in both stages. The node:20-alpine base unpacks npm from the Node tarball rather than installing an apk package. On the Debian stage npm is not installed as a separate apt package either: NodeSource's `nodejs` package ships it (`dpkg -S /usr/bin/npm` resolves to `nodejs`), so `apt-get remove npm` is a no-op and removal by path is the only option there too. corepack is deliberately left in place in both stages. It has no node_modules tree and ships no tar package metadata, so no scannable vulnerable tar component remains in either stage after this change. node-tar code is webpacked into its `dist/lib/corepack.cjs` bundle without package metadata, so its version cannot be determined from the image. Both path sets were validated in live containers against the real node:20-alpine and python:3.11-slim + NodeSource setup_20.x base images: every target existed before deletion, a filesystem-wide search for `*/node_modules/tar/package.json` returned nothing afterwards, npm and npx were gone from PATH, and node (plus python3 on the Debian stage) still ran. The api-only stage has no Node at all and is unaffected. * ci: build and scan every runtime base in scan-next-build, not just api-only scan-next-build answers "would an image built from main today be clean?", but it only builds `target: api-only`, which is python:3.11-slim with no Node in it at all. The two images that carry a Node runtime, cp-only (node:20-alpine) and standalone (Debian plus NodeSource nodejs), were never built here, so a Node-side finding had no fresh-build gate and could only surface in scan-published, after it had already shipped under a tag. That is how npm's bundled tar 6.2.1 reached every published image with a CRITICAL against it. The job becomes a matrix over the three targets, mirroring the matrix scan-published already uses. api-only keeps covering the Debian family for the reason recorded in the old comment; cp-only and standalone add the Node surface. The build args are unchanged, so the legs that read them stay on the slim runtime surface and cp-only ignores them. Each leg runs on its own runner, so this does not lengthen the job or add disk pressure to the existing build. * fix(docker): fail the build if the npm paths move, and exercise the Node runtimes Follow-up to the npm removal on the same branch, from review. A bare `rm -rf` on a path a future base image no longer uses succeeds silently, so a Node or NodeSource bump could quietly start shipping npm again. Both runtime stages now assert the path exists before removing it and assert npm is gone afterwards, which fails the build instead. The removals also had no runtime coverage. `build-docker-images` gated both `load:` and the smoke test on `variant == 'slim'`, and cp-only is a full variant, so the alpine control-plane image was built and thrown away, never started. It now runs the smoke test (it needs no LLM credentials); the gate moves to an explicit `smoke` matrix field so the two heavy full variants stay build-only. `docker/test-image.sh` only probed the API port for standalone, which would pass on an image whose control plane never came up - the two are separate processes. It now waits on the control plane's own health endpoint too when the target serves both. Verified locally on arm64: both targets build with the guards in place, `npm`/`npx` are gone from PATH and no `node_modules/tar/package.json` remains in either image, the cp-only smoke test passes, and the standalone image serves both 8888 and 9999. Claude-Session: https://claude.ai/code/session_011n8KQc8sCe4cJJCd8Cv69n --------- Co-authored-by: Nicolò Boschi <boschi1997@gmail.com>
272 lines
9.7 KiB
Bash
Executable File
272 lines
9.7 KiB
Bash
Executable File
#!/bin/bash
|
|
#
|
|
# Docker Smoke Test Script
|
|
#
|
|
# Tests that a Hindsight Docker image starts correctly and becomes healthy.
|
|
# Can be run locally or in CI pipelines.
|
|
#
|
|
# Usage:
|
|
# ./docker/test-image.sh <image> [target]
|
|
#
|
|
# Arguments:
|
|
# image - Docker image to test (e.g., hindsight-api:test, ghcr.io/vectorize-io/hindsight:latest)
|
|
# target - Optional: 'cp-only' for control plane, otherwise assumes API image (default: api)
|
|
#
|
|
# Environment variables:
|
|
# HINDSIGHT_API_LLM_API_KEY - Required for API/standalone images (LLM verification)
|
|
# HINDSIGHT_API_LLM_PROVIDER - LLM provider (default: openai)
|
|
# HINDSIGHT_API_LLM_MODEL - LLM model (default: gpt-4o-mini)
|
|
# HINDSIGHT_API_EMBEDDINGS_PROVIDER - Embeddings provider (optional, for slim images: openai, cohere, tei)
|
|
# HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY - OpenAI API key for embeddings (optional)
|
|
# HINDSIGHT_API_RERANKER_PROVIDER - Reranker provider (optional, for slim images: cohere, tei)
|
|
# HINDSIGHT_API_COHERE_API_KEY - Cohere API key for reranking (optional)
|
|
# SMOKE_TEST_TIMEOUT - Timeout in seconds (default: 120)
|
|
# SMOKE_TEST_CONTAINER_NAME - Container name (default: hindsight-smoke-test)
|
|
#
|
|
# Examples:
|
|
# # Test a locally built full image
|
|
# ./docker/test-image.sh hindsight-api:test
|
|
#
|
|
# # Test a released image
|
|
# ./docker/test-image.sh ghcr.io/vectorize-io/hindsight:latest
|
|
#
|
|
# # Test control plane image
|
|
# ./docker/test-image.sh hindsight-control-plane:test cp-only
|
|
#
|
|
# # Test slim image with external providers
|
|
# export HINDSIGHT_API_LLM_API_KEY=sk_xxx
|
|
# export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
|
|
# export HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=sk-xxx
|
|
# export HINDSIGHT_API_RERANKER_PROVIDER=cohere
|
|
# export HINDSIGHT_API_COHERE_API_KEY=xxx
|
|
# ./docker/test-image.sh hindsight-slim:test
|
|
#
|
|
# Exit codes:
|
|
# 0 - Success (container healthy)
|
|
# 1 - Failure (container not healthy within timeout)
|
|
# 2 - Invalid arguments
|
|
#
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
|
|
|
# Colors for output
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[0;33m'
|
|
NC='\033[0m' # No Color
|
|
|
|
# Configuration
|
|
IMAGE="${1:-}"
|
|
TARGET="${2:-api}"
|
|
TIMEOUT="${SMOKE_TEST_TIMEOUT:-120}"
|
|
CONTAINER_NAME="${SMOKE_TEST_CONTAINER_NAME:-hindsight-smoke-test}"
|
|
LLM_PROVIDER="${HINDSIGHT_API_LLM_PROVIDER:-openai}"
|
|
LLM_MODEL="${HINDSIGHT_API_LLM_MODEL:-gpt-4o-mini}"
|
|
|
|
# Validate arguments
|
|
if [ -z "$IMAGE" ]; then
|
|
echo -e "${RED}Error: Image argument is required${NC}"
|
|
echo ""
|
|
echo "Usage: $0 <image> [target]"
|
|
echo ""
|
|
echo "Examples:"
|
|
echo " $0 hindsight-api:test"
|
|
echo " $0 ghcr.io/vectorize-io/hindsight:latest"
|
|
echo " $0 hindsight-control-plane:test cp-only"
|
|
exit 2
|
|
fi
|
|
|
|
# Determine health endpoint based on target
|
|
# CP_PORT is set only when the image also serves the control plane on its own
|
|
# port: cp-only is checked through HEALTH_PORT already, standalone runs both
|
|
# processes and would otherwise be declared healthy on the API alone.
|
|
CP_PORT=""
|
|
if [ "$TARGET" = "cp-only" ]; then
|
|
HEALTH_PORT=9999
|
|
HEALTH_PATH="/api/health"
|
|
NEEDS_LLM=false
|
|
else
|
|
HEALTH_PORT=8888
|
|
HEALTH_PATH="/health"
|
|
NEEDS_LLM=true
|
|
if [ "$TARGET" = "standalone" ]; then
|
|
CP_PORT=9999
|
|
fi
|
|
fi
|
|
|
|
# Check for required environment variables
|
|
if [ "$NEEDS_LLM" = true ] && [ "$LLM_PROVIDER" != "vertexai" ] && [ -z "${HINDSIGHT_API_LLM_API_KEY:-}" ]; then
|
|
echo -e "${RED}Error: HINDSIGHT_API_LLM_API_KEY environment variable is required for API/standalone images${NC}"
|
|
echo "Set it with: export HINDSIGHT_API_LLM_API_KEY=your-api-key"
|
|
exit 2
|
|
fi
|
|
|
|
# Cleanup function
|
|
cleanup() {
|
|
echo "Cleaning up..."
|
|
docker stop "$CONTAINER_NAME" 2>/dev/null || true
|
|
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
|
}
|
|
|
|
# Set trap to cleanup on exit
|
|
trap cleanup EXIT
|
|
|
|
echo -e "${YELLOW}Starting smoke test for: ${IMAGE}${NC}"
|
|
echo " Target: $TARGET"
|
|
echo " Health endpoint: http://localhost:${HEALTH_PORT}${HEALTH_PATH}"
|
|
echo " Timeout: ${TIMEOUT}s"
|
|
echo ""
|
|
|
|
# Remove any existing container with the same name
|
|
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
|
|
|
# Start container based on target type
|
|
echo "Starting container..."
|
|
if [ "$TARGET" = "cp-only" ]; then
|
|
docker run -d --name "$CONTAINER_NAME" \
|
|
-p "${HEALTH_PORT}:${HEALTH_PORT}" \
|
|
"$IMAGE"
|
|
else
|
|
# Build docker run command with required and optional env vars
|
|
DOCKER_CMD="docker run -d --name $CONTAINER_NAME"
|
|
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_PROVIDER=$LLM_PROVIDER"
|
|
if [ -n "${HINDSIGHT_API_LLM_API_KEY:-}" ]; then
|
|
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_API_KEY=${HINDSIGHT_API_LLM_API_KEY}"
|
|
fi
|
|
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_MODEL=$LLM_MODEL"
|
|
|
|
# Add Vertex AI config if provider is vertexai
|
|
if [ "$LLM_PROVIDER" = "vertexai" ]; then
|
|
if [ -n "${HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY:-}" ]; then
|
|
DOCKER_CMD="$DOCKER_CMD -v ${HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY}:/tmp/gcp-credentials.json:ro"
|
|
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/tmp/gcp-credentials.json"
|
|
fi
|
|
if [ -n "${HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID:-}" ]; then
|
|
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=${HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID}"
|
|
fi
|
|
if [ -n "${HINDSIGHT_API_LLM_VERTEXAI_REGION:-}" ]; then
|
|
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_VERTEXAI_REGION=${HINDSIGHT_API_LLM_VERTEXAI_REGION}"
|
|
fi
|
|
fi
|
|
|
|
# Add optional embeddings provider config
|
|
if [ -n "${HINDSIGHT_API_EMBEDDINGS_PROVIDER:-}" ]; then
|
|
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_EMBEDDINGS_PROVIDER=${HINDSIGHT_API_EMBEDDINGS_PROVIDER}"
|
|
fi
|
|
if [ -n "${HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY:-}" ]; then
|
|
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=${HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY}"
|
|
fi
|
|
|
|
# Add optional reranker provider config
|
|
if [ -n "${HINDSIGHT_API_RERANKER_PROVIDER:-}" ]; then
|
|
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_RERANKER_PROVIDER=${HINDSIGHT_API_RERANKER_PROVIDER}"
|
|
fi
|
|
if [ -n "${HINDSIGHT_API_COHERE_API_KEY:-}" ]; then
|
|
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_COHERE_API_KEY=${HINDSIGHT_API_COHERE_API_KEY}"
|
|
fi
|
|
|
|
DOCKER_CMD="$DOCKER_CMD -p ${HEALTH_PORT}:${HEALTH_PORT}"
|
|
if [ -n "$CP_PORT" ]; then
|
|
DOCKER_CMD="$DOCKER_CMD -p ${CP_PORT}:${CP_PORT}"
|
|
fi
|
|
DOCKER_CMD="$DOCKER_CMD $IMAGE"
|
|
|
|
eval $DOCKER_CMD
|
|
fi
|
|
|
|
# Wait for health endpoint
|
|
echo "Waiting for health endpoint at http://localhost:${HEALTH_PORT}${HEALTH_PATH}..."
|
|
start_time=$(date +%s)
|
|
|
|
for i in $(seq 1 "$TIMEOUT"); do
|
|
if curl -sf "http://localhost:${HEALTH_PORT}${HEALTH_PATH}" > /dev/null 2>&1; then
|
|
end_time=$(date +%s)
|
|
duration=$((end_time - start_time))
|
|
echo ""
|
|
echo -e "${GREEN}Container is healthy after ${duration}s${NC}"
|
|
echo ""
|
|
echo "=== Health Response ==="
|
|
curl -s "http://localhost:${HEALTH_PORT}${HEALTH_PATH}" | python3 -m json.tool 2>/dev/null || curl -s "http://localhost:${HEALTH_PORT}${HEALTH_PATH}"
|
|
echo ""
|
|
|
|
# Verify the control plane too when the image serves both. It is a
|
|
# separate process from the API, so an API-only probe would pass on an
|
|
# image whose control plane never came up.
|
|
if [ -n "$CP_PORT" ]; then
|
|
echo ""
|
|
echo "=== Control Plane Health (port ${CP_PORT}) ==="
|
|
cp_healthy=false
|
|
for j in $(seq 1 "$TIMEOUT"); do
|
|
if curl -sf "http://localhost:${CP_PORT}/api/health" > /dev/null 2>&1; then
|
|
cp_healthy=true
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
if [ "$cp_healthy" != true ]; then
|
|
echo ""
|
|
echo "=== Container Logs (last 50 lines) ==="
|
|
docker logs "$CONTAINER_NAME" 2>&1 | tail -50
|
|
echo ""
|
|
echo -e "${RED}Control plane never became healthy on port ${CP_PORT}${NC}"
|
|
exit 1
|
|
fi
|
|
curl -s "http://localhost:${CP_PORT}/api/health"
|
|
echo ""
|
|
fi
|
|
|
|
# Run retain/recall smoke test for API targets
|
|
if [ "$TARGET" != "cp-only" ]; then
|
|
echo ""
|
|
echo "=== Retain/Recall Smoke Test ==="
|
|
if ! "$REPO_ROOT/scripts/smoke-test-slim.sh" "http://localhost:${HEALTH_PORT}"; then
|
|
echo ""
|
|
echo "=== Container Logs (last 50 lines) ==="
|
|
docker logs "$CONTAINER_NAME" 2>&1 | tail -50
|
|
echo ""
|
|
echo -e "${RED}Smoke test FAILED${NC}"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
echo "=== Container Logs (last 50 lines) ==="
|
|
docker logs "$CONTAINER_NAME" 2>&1 | tail -50
|
|
echo ""
|
|
echo -e "${GREEN}Smoke test PASSED${NC}"
|
|
exit 0
|
|
fi
|
|
|
|
# Show progress every 10 seconds
|
|
if [ $((i % 10)) -eq 0 ]; then
|
|
echo " Still waiting... (${i}s)"
|
|
fi
|
|
|
|
# Check if container is still running
|
|
if ! docker ps -q -f "name=$CONTAINER_NAME" | grep -q .; then
|
|
echo ""
|
|
echo -e "${RED}Container exited unexpectedly!${NC}"
|
|
echo ""
|
|
echo "=== Container Logs ==="
|
|
docker logs "$CONTAINER_NAME" 2>&1
|
|
echo ""
|
|
echo -e "${RED}Smoke test FAILED${NC}"
|
|
exit 1
|
|
fi
|
|
|
|
sleep 1
|
|
done
|
|
|
|
# Timeout reached
|
|
echo ""
|
|
echo -e "${RED}Container failed to become healthy after ${TIMEOUT}s${NC}"
|
|
echo ""
|
|
echo "=== Container Logs ==="
|
|
docker logs "$CONTAINER_NAME" 2>&1
|
|
echo ""
|
|
echo -e "${RED}Smoke test FAILED${NC}"
|
|
exit 1
|