← all scripts

.github/scripts/functional_container_test.sh

93 of 93 statements covered (100.00%).

coveredmissednever traced by Bash (not counted)A line ending in … continues the statement above it and shares its fate.

111#!/usr/bin/env bash
2# =============================================================================
3# functional_container_test.sh — boot a distroless service image under the
4# deployment manifests' pod-equivalent runtime constraints and assert its
5# serving contract.
6# =============================================================================
7#
8# What it asserts, in order:
9# 1. The container boots to a serving state under the same constraints the
10# pod securityContext enforces: read-only root filesystem, tmpfs /tmp,
11# uid:gid 1000:1000, all capabilities dropped, no-new-privileges.
12# 2. Each --probe "path=code[=body-substring]" returns the expected HTTP
13# status (and body substring when given) — covering liveness/readiness
14# endpoints, auth fail-closed 503s, and degraded-dependency 503s.
15# 3. Each --exec-python payload runs inside the live container via
16# `docker exec` — the same mechanism kubelet uses for exec probes and
17# preStop hooks. The images ship no shell, so python is the only
18# executable; this proves the manifests' ["python", "-c", ...] command
19# shapes actually work against the running distroless container.
20# 4. Optional --min-uptime N: the service must still be serving N seconds
21# after boot (guards "boots then crash-loops on unreachable deps").
22# 5. `docker stop` (SIGTERM, then SIGKILL after --stop-timeout) exits with
23# --expect-stop-exit: 0 for the uvicorn services' graceful shutdown,
24# 143 for inference-monitor which has no SIGTERM handler today.
25#
26# Kubernetes-dependent behavior (RBAC, reconciliation, NetworkPolicy) is out
27# of scope here — integration:kind:cluster-e2e owns it. This script owns the
28# container-level contract that kind deliberately does not probe.
29#
30# Usage:
31# functional_container_test.sh --image svc:ci --name svc-fn --host-port 18080 \
32# [--container-port 8080] [--wait-path /healthz] [--env K=V]... \
33# [--kubeconfig FILE] [--probe "path=code[=substring]"]... \
34# [--exec-python "code"]... [--min-uptime N] \
35# [--expect-stop-exit 0] [--stop-timeout 30] [--boot-timeout 60]
36# =============================================================================
37
3813set -euo pipefail
39
4013IMAGE=""
4113NAME=""
4213HOST_PORT=""
4313CONTAINER_PORT="8080"
4413WAIT_PATH="/healthz"
4513KUBECONFIG_FILE=""
4613MIN_UPTIME="0"
4713EXPECT_STOP_EXIT="0"
4813STOP_TIMEOUT="30"
4913BOOT_TIMEOUT="60"
5013ENVS=()
5113PROBES=()
5213EXEC_SNIPPETS=()
53
5469while [ $# -gt 0 ]; do
5557 case "$1" in
5626 --image) IMAGE="$2"; shift 2 ;;
5726 --name) NAME="$2"; shift 2 ;;
5824 --host-port) HOST_PORT="$2"; shift 2 ;;
592 --container-port) CONTAINER_PORT="$2"; shift 2 ;;
602 --wait-path) WAIT_PATH="$2"; shift 2 ;;
614 --env) ENVS+=("$2"); shift 2 ;;
622 --kubeconfig) KUBECONFIG_FILE="$2"; shift 2 ;;
638 --probe) PROBES+=("$2"); shift 2 ;;
644 --exec-python) EXEC_SNIPPETS+=("$2"); shift 2 ;;
656 --min-uptime) MIN_UPTIME="$2"; shift 2 ;;
664 --expect-stop-exit) EXPECT_STOP_EXIT="$2"; shift 2 ;;
672 --stop-timeout) STOP_TIMEOUT="$2"; shift 2 ;;
682 --boot-timeout) BOOT_TIMEOUT="$2"; shift 2 ;;
692 *) echo "unknown argument: $1" >&2; exit 2 ;;
70 esac
71done
72
7336if [ -z "${IMAGE}" ] || [ -z "${NAME}" ] || [ -z "${HOST_PORT}" ]; then
741 echo "required: --image, --name, --host-port" >&2
751 exit 2
76fi
77
78fail() {
798 echo "::error::${NAME}: $1"
808 echo "===== ${NAME}: container logs ====="
818 docker logs "${NAME}" 2>&1 || true
828 exit 1
83}
84
8511cleanup() { docker rm -f "${NAME}" >/dev/null 2>&1 || true; }
8611trap cleanup EXIT
87
88# Pod-equivalent runtime constraints, mirroring the container securityContext
89# in lambda/kubectl-applier-simple/manifests/3*.yaml: readOnlyRootFilesystem,
90# runAsUser/runAsGroup 1000, capabilities drop ALL, allowPrivilegeEscalation
91# false, and the /tmp emptyDir the manifests mount.
9211run_args=(
93 -d --name "${NAME}"
94 --read-only
95 --tmpfs "/tmp:rw,size=64m,mode=1777"
96 --user 1000:1000
97 --cap-drop ALL
98 --security-opt no-new-privileges
99 -p "127.0.0.1:${HOST_PORT}:${CONTAINER_PORT}"
100)
1012for pair in ${ENVS[@]+"${ENVS[@]}"}; do
1022 run_args+=(-e "${pair}")
103done
10411if [ -n "${KUBECONFIG_FILE}" ]; then
105 # A static kubeconfig pointing at an unreachable apiserver: the kubernetes
106 # client parses it at startup without connecting, which is exactly what
107 # lets these services reach their serving state outside a cluster.
1081 run_args+=(-v "${KUBECONFIG_FILE}:/kubeconfig:ro" -e KUBECONFIG=/kubeconfig)
109fi
110
11111docker run "${run_args[@]}" "${IMAGE}"
112
113# --- 1. Wait for the serving state --------------------------------------
11411base_url="http://127.0.0.1:${HOST_PORT}"
11511deadline=$((SECONDS + BOOT_TIMEOUT))
11613while :; do
11730 code="$(curl -s -o /dev/null -w '%{http_code}' "${base_url}${WAIT_PATH}" || true)"
11822 [ "${code}" = "200" ] && break
1198 if [ "$(docker inspect -f '{{.State.Running}}' "${NAME}" 2>/dev/null)" != "true" ]; then
1201 fail "container exited during startup (last ${WAIT_PATH} code: ${code})"
121 fi
1224 [ "${SECONDS}" -ge "${deadline}" ] && fail "${WAIT_PATH} never returned 200 within ${BOOT_TIMEOUT}s (last: ${code})"
1232 sleep 2
124done
1259echo "${NAME}: serving (${WAIT_PATH} -> 200)"
126
127# --- 2. HTTP contract probes ---------------------------------------------
1284for spec in ${PROBES[@]+"${PROBES[@]}"}; do
1294 path="${spec%%=*}"
1304 rest="${spec#*=}"
1314 expected_code="${rest%%=*}"
1324 expected_body=""
1336 [ "${rest}" != "${expected_code}" ] && expected_body="${rest#*=}"
134
1358 body_file="$(mktemp)"
1368 actual_code="$(curl -s -o "${body_file}" -w '%{http_code}' "${base_url}${path}" || true)"
1374 if [ "${actual_code}" != "${expected_code}" ]; then
1382 echo "response body: $(cat "${body_file}")"
1391 rm -f "${body_file}"
1401 fail "GET ${path}: expected HTTP ${expected_code}, got ${actual_code}"
141 fi
1425 if [ -n "${expected_body}" ] && ! grep -q "${expected_body}" "${body_file}"; then
1432 echo "response body: $(cat "${body_file}")"
1441 rm -f "${body_file}"
1451 fail "GET ${path}: body does not contain '${expected_body}'"
146 fi
1472 rm -f "${body_file}"
1482 if [ -n "${expected_body}" ]; then
1491 echo "${NAME}: GET ${path} -> ${actual_code} (body matches \"${expected_body}\") OK"
150 else
1511 echo "${NAME}: GET ${path} -> ${actual_code} OK"
152 fi
153done
154
155# --- 3. Kubelet exec-command shapes (probes / preStop hooks) --------------
1562for snippet in ${EXEC_SNIPPETS[@]+"${EXEC_SNIPPETS[@]}"}; do
1572 if ! docker exec "${NAME}" python -c "${snippet}"; then
1581 fail "exec command failed in live container: python -c '${snippet}'"
159 fi
1601 echo "${NAME}: exec python -c '${snippet}' OK"
161done
162
163# --- 4. Stability under unreachable dependencies --------------------------
1646if [ "${MIN_UPTIME}" -gt 0 ]; then
1653 sleep "${MIN_UPTIME}"
1666 if [ "$(docker inspect -f '{{.State.Running}}' "${NAME}")" != "true" ]; then
1671 fail "container died within ${MIN_UPTIME}s of becoming healthy"
168 fi
1694 code="$(curl -s -o /dev/null -w '%{http_code}' "${base_url}${WAIT_PATH}" || true)"
1703 [ "${code}" = "200" ] || fail "${WAIT_PATH} degraded to ${code} after ${MIN_UPTIME}s"
1711 echo "${NAME}: still serving after ${MIN_UPTIME}s with unreachable dependencies OK"
172fi
173
174# --- 5. Shutdown contract --------------------------------------------------
1754docker stop -t "${STOP_TIMEOUT}" "${NAME}" >/dev/null
1768exit_code="$(docker inspect -f '{{.State.ExitCode}}' "${NAME}")"
1774if [ "${exit_code}" != "${EXPECT_STOP_EXIT}" ]; then
1781 fail "SIGTERM shutdown: expected exit ${EXPECT_STOP_EXIT}, got ${exit_code}"
179fi
1803echo "${NAME}: SIGTERM shutdown exited ${exit_code} OK"
1813echo "${NAME}: functional container test PASSED"