Files
Rainier Schlekewey 466517ff9b fix(docker): drop npm from the runtime stages and scan every runtime base (#3960)
* 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>
2026-09-01 10:28:28 +02:00

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