#!/usr/bin/env bash ##################################################################################### #### docker-smoke-tests.sh — Verify Docker daemon and containers are healthy #### #### Checks daemon, API, lifecycle, networking, volumes, DNS, compose, images. #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version: 1.0 #### #### #### #### Usage: ./docker-smoke-tests.sh #### #### COMPOSE_FILE=/path/to/docker-compose.yml ./docker-smoke-tests.sh #### #### #### #### See --help for all options. #### ##################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── TEST_IMAGE="${TEST_IMAGE:-alpine:latest}" COMPOSE_FILE="${COMPOSE_FILE:-}" SKIP_LIFECYCLE="${SKIP_LIFECYCLE:-false}" SKIP_NETWORK="${SKIP_NETWORK:-false}" SKIP_VOLUME="${SKIP_VOLUME:-false}" SKIP_BUILD="${SKIP_BUILD:-false}" DNS_TEST_DOMAIN="${DNS_TEST_DOMAIN:-google.com}" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" COLOR="${COLOR:-auto}" VERBOSE="${VERBOSE:-false}" # ── State ───────────────────────────────────────────────────────────── PASS=0 FAIL=0 SKIP=0 TOTAL=0 RESULTS=() START_TIME="" # ── Test artifact names ────────────────────────────────────────────── SMOKE_PREFIX="smoke-test-$$" SMOKE_CONTAINER="${SMOKE_PREFIX}-ctr" SMOKE_PORT_CONTAINER="${SMOKE_PREFIX}-port" SMOKE_DNS_CONTAINER="${SMOKE_PREFIX}-dns" SMOKE_VOL_CONTAINER="${SMOKE_PREFIX}-vol" SMOKE_NET_CONTAINER="${SMOKE_PREFIX}-net" SMOKE_MEM_CONTAINER="${SMOKE_PREFIX}-mem" SMOKE_VOLUME="${SMOKE_PREFIX}-vol" SMOKE_NETWORK="${SMOKE_PREFIX}-net" SMOKE_BUILD_TAG="${SMOKE_PREFIX}-img" SMOKE_PORT=$((47000 + (RANDOM % 1000))) # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' BOLD='\033[1m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${BLUE}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${BLUE}[DEBUG]${RESET} $*"; fi; } # ── Test Result Recording ───────────────────────────────────────────── record_pass() { local name="$1" detail="${2:-}" ((PASS++)) || true; ((TOTAL++)) || true RESULTS+=("PASS|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name}${detail:+ (${detail})}" else echo -e " ${GREEN}✓${RESET} ${name}${detail:+ — ${detail}}"; fi } record_fail() { local name="$1" detail="${2:-}" ((FAIL++)) || true; ((TOTAL++)) || true RESULTS+=("FAIL|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "not ok ${TOTAL} - ${name}" [[ -n "$detail" ]] && echo " # ${detail}" else echo -e " ${RED}✗${RESET} ${name}${detail:+ — ${detail}}"; fi } record_skip() { local name="$1" reason="${2:-}" ((SKIP++)) || true; ((TOTAL++)) || true RESULTS+=("SKIP|${name}|${reason}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name} # SKIP ${reason}" else echo -e " ${YELLOW}⊘${RESET} ${name}${reason:+ — ${reason}}"; fi } # ── Helpers ─────────────────────────────────────────────────────────── has_cmd() { command -v "$1" >/dev/null 2>&1; } remove_container() { local name="$1" docker rm -f "$name" >/dev/null 2>&1 || true } section() { if [[ "$OUTPUT_FORMAT" != "tap" ]]; then echo ""; echo -e "${BOLD}$1${RESET}"; fi } # ── Cleanup ─────────────────────────────────────────────────────────── # shellcheck disable=SC2317 cleanup() { verbose "Cleaning up test artifacts..." remove_container "$SMOKE_CONTAINER" remove_container "$SMOKE_PORT_CONTAINER" remove_container "$SMOKE_DNS_CONTAINER" remove_container "$SMOKE_VOL_CONTAINER" remove_container "$SMOKE_NET_CONTAINER" remove_container "$SMOKE_MEM_CONTAINER" docker volume rm -f "$SMOKE_VOLUME" >/dev/null 2>&1 || true docker network rm "$SMOKE_NETWORK" >/dev/null 2>&1 || true docker rmi -f "$SMOKE_BUILD_TAG" >/dev/null 2>&1 || true } trap cleanup EXIT # ══════════════════════════════════════════════════════════════════════ # TEST FUNCTIONS # ══════════════════════════════════════════════════════════════════════ # ── 1. Docker daemon running ───────────────────────────────────────── test_daemon_running() { if has_cmd systemctl; then if systemctl is-active --quiet docker 2>/dev/null; then record_pass "Docker daemon running" "systemctl active" else record_fail "Docker daemon running" "systemctl inactive"; fi elif has_cmd service; then if service docker status >/dev/null 2>&1; then record_pass "Docker daemon running" "service running" else record_fail "Docker daemon running" "service stopped"; fi elif docker info >/dev/null 2>&1; then record_pass "Docker daemon running" "docker info ok" else record_fail "Docker daemon running" "cannot determine status"; fi } # ── 2. Docker API responsive ───────────────────────────────────────── test_api_responsive() { local output if output=$(timeout 10 docker info 2>&1); then local ver; ver=$(echo "$output" | grep -i "Server Version" | head -1 | awk '{print $NF}') || true record_pass "Docker API responsive" "server ${ver:-unknown}" else record_fail "Docker API responsive" "docker info timed out or failed"; fi } # ── 3. Docker socket accessible ────────────────────────────────────── test_socket_accessible() { local socket="/var/run/docker.sock" if [[ -S "$socket" ]]; then if [[ -r "$socket" && -w "$socket" ]]; then record_pass "Docker socket accessible" "$socket" else record_fail "Docker socket accessible" "$socket not readable/writable"; fi elif docker info >/dev/null 2>&1; then record_pass "Docker socket accessible" "non-default socket" else record_fail "Docker socket accessible" "$socket not found"; fi } # ── 4. Container lifecycle ─────────────────────────────────────────── test_container_lifecycle() { if [[ "$SKIP_LIFECYCLE" == "true" ]]; then record_skip "Container lifecycle" "SKIP_LIFECYCLE=true"; return; fi remove_container "$SMOKE_CONTAINER" if ! docker create --name "$SMOKE_CONTAINER" "$TEST_IMAGE" sleep 30 >/dev/null 2>&1; then record_fail "Container lifecycle" "docker create failed" return fi if ! docker start "$SMOKE_CONTAINER" >/dev/null 2>&1; then record_fail "Container lifecycle" "docker start failed" return fi local exec_output exec_output=$(docker exec "$SMOKE_CONTAINER" echo "smoke-ok" 2>&1) || true if [[ "$exec_output" != "smoke-ok" ]]; then record_fail "Container lifecycle" "docker exec failed" return fi if ! docker stop -t 5 "$SMOKE_CONTAINER" >/dev/null 2>&1; then record_fail "Container lifecycle" "docker stop failed" return fi if ! docker rm "$SMOKE_CONTAINER" >/dev/null 2>&1; then record_fail "Container lifecycle" "docker rm failed" return fi record_pass "Container lifecycle" "create/start/exec/stop/rm" } # ── 5. Port binding ────────────────────────────────────────────────── test_port_binding() { if [[ "$SKIP_LIFECYCLE" == "true" ]]; then record_skip "Port binding" "SKIP_LIFECYCLE=true"; return; fi if ! has_cmd curl; then record_skip "Port binding" "curl not installed"; return; fi remove_container "$SMOKE_PORT_CONTAINER" if ! docker run -d --name "$SMOKE_PORT_CONTAINER" \ -p "${SMOKE_PORT}:80" \ "$TEST_IMAGE" sh -c 'mkdir -p /var/www && echo "smoke-ok" > /var/www/index.html && httpd -f -p 80 -h /var/www 2>/dev/null || { while true; do echo -e "HTTP/1.1 200 OK\r\nContent-Length: 9\r\n\r\nsmoke-ok\n" | nc -l -p 80 2>/dev/null || break; done; }' >/dev/null 2>&1; then record_fail "Port binding" "failed to start container with port mapping" return fi sleep 2 local response response=$(curl -sf --max-time 5 "http://localhost:${SMOKE_PORT}/" 2>/dev/null) || true remove_container "$SMOKE_PORT_CONTAINER" if [[ "$response" == *"smoke-ok"* ]]; then record_pass "Port binding" "curl localhost:${SMOKE_PORT}" else record_fail "Port binding" "no response on localhost:${SMOKE_PORT}" fi } # ── 6. Container DNS ───────────────────────────────────────────────── test_container_dns() { if [[ "$SKIP_LIFECYCLE" == "true" ]]; then record_skip "Container DNS" "SKIP_LIFECYCLE=true"; return; fi remove_container "$SMOKE_DNS_CONTAINER" local dns_output dns_output=$(docker run --rm --name "$SMOKE_DNS_CONTAINER" "$TEST_IMAGE" \ sh -c "nslookup ${DNS_TEST_DOMAIN} 2>/dev/null || getent hosts ${DNS_TEST_DOMAIN} 2>/dev/null || ping -c1 -W3 ${DNS_TEST_DOMAIN} 2>/dev/null" 2>&1) || true if [[ -n "$dns_output" ]] && ! echo "$dns_output" | grep -qi "can't resolve\|not found\|failure\|NXDOMAIN"; then record_pass "Container DNS" "${DNS_TEST_DOMAIN}" else record_fail "Container DNS" "failed to resolve ${DNS_TEST_DOMAIN}" fi } # ── 7. Volume mount ────────────────────────────────────────────────── test_volume_mount() { if [[ "$SKIP_VOLUME" == "true" ]]; then record_skip "Volume mount" "SKIP_VOLUME=true"; return; fi docker volume rm -f "$SMOKE_VOLUME" >/dev/null 2>&1 || true remove_container "$SMOKE_VOL_CONTAINER" if ! docker volume create "$SMOKE_VOLUME" >/dev/null 2>&1; then record_fail "Volume mount" "docker volume create failed" return fi local write_result write_result=$(docker run --rm --name "$SMOKE_VOL_CONTAINER" \ -v "${SMOKE_VOLUME}:/data" "$TEST_IMAGE" \ sh -c 'echo "smoke-vol-ok" > /data/test.txt && cat /data/test.txt' 2>&1) || true docker volume rm -f "$SMOKE_VOLUME" >/dev/null 2>&1 || true if [[ "$write_result" == "smoke-vol-ok" ]]; then record_pass "Volume mount" "write/read verified" else record_fail "Volume mount" "write/read mismatch" fi } # ── 8. Network create/connect ──────────────────────────────────────── test_network_create() { if [[ "$SKIP_NETWORK" == "true" ]]; then record_skip "Network create/connect" "SKIP_NETWORK=true"; return; fi docker network rm "$SMOKE_NETWORK" >/dev/null 2>&1 || true remove_container "$SMOKE_NET_CONTAINER" if ! docker network create --driver bridge "$SMOKE_NETWORK" >/dev/null 2>&1; then record_fail "Network create/connect" "docker network create failed" return fi local net_output net_output=$(docker run --rm --name "$SMOKE_NET_CONTAINER" \ --network "$SMOKE_NETWORK" "$TEST_IMAGE" \ sh -c 'ip addr show 2>/dev/null || ifconfig 2>/dev/null' 2>&1) || true docker network rm "$SMOKE_NETWORK" >/dev/null 2>&1 || true if [[ -n "$net_output" ]]; then record_pass "Network create/connect" "bridge network" else record_fail "Network create/connect" "container failed to attach to network" fi } # ── 9. Image pull ──────────────────────────────────────────────────── test_image_pull() { if docker pull "$TEST_IMAGE" >/dev/null 2>&1; then record_pass "Image pull" "$TEST_IMAGE" else record_fail "Image pull" "failed to pull $TEST_IMAGE" fi } # ── 10. Image build ────────────────────────────────────────────────── test_image_build() { if [[ "$SKIP_BUILD" == "true" ]]; then record_skip "Image build" "SKIP_BUILD=true"; return; fi docker rmi -f "$SMOKE_BUILD_TAG" >/dev/null 2>&1 || true if echo "FROM alpine:latest" | docker build -t "$SMOKE_BUILD_TAG" - >/dev/null 2>&1; then docker rmi -f "$SMOKE_BUILD_TAG" >/dev/null 2>&1 || true record_pass "Image build" "inline Dockerfile" else record_fail "Image build" "docker build failed" fi } # ── 11. Docker Compose ─────────────────────────────────────────────── test_compose_stack() { if [[ -z "$COMPOSE_FILE" ]]; then record_skip "Compose stack" "COMPOSE_FILE not set" return fi if [[ ! -f "$COMPOSE_FILE" ]]; then record_fail "Compose stack" "${COMPOSE_FILE} not found" return fi local compose_cmd="" if docker compose version >/dev/null 2>&1; then compose_cmd="docker compose" elif has_cmd docker-compose; then compose_cmd="docker-compose" else record_skip "Compose stack" "neither 'docker compose' nor 'docker-compose' available" return fi local ps_output expected_count running_count ps_output=$($compose_cmd -f "$COMPOSE_FILE" ps --format json 2>/dev/null) || true if [[ -z "$ps_output" ]]; then ps_output=$($compose_cmd -f "$COMPOSE_FILE" ps 2>/dev/null) || true if [[ -z "$ps_output" ]]; then record_fail "Compose stack" "could not read compose project status" return fi expected_count=$(echo "$ps_output" | tail -n +2 | wc -l) running_count=$(echo "$ps_output" | tail -n +2 | grep -ciE "up|running" || true) else expected_count=$(echo "$ps_output" | grep -c '"Service"' 2>/dev/null || echo "$ps_output" | wc -l) running_count=$(echo "$ps_output" | grep -ciE '"running"' 2>/dev/null || true) fi if [[ "$expected_count" -eq 0 ]]; then record_fail "Compose stack" "no services found" elif [[ "$running_count" -ge "$expected_count" ]]; then record_pass "Compose stack" "${running_count}/${expected_count} services running" else record_fail "Compose stack" "${running_count}/${expected_count} services running" fi } # ── 12. Resource limits ────────────────────────────────────────────── test_resource_limits() { if [[ "$SKIP_LIFECYCLE" == "true" ]]; then record_skip "Resource limits" "SKIP_LIFECYCLE=true"; return; fi remove_container "$SMOKE_MEM_CONTAINER" local mem_limit mem_limit=$(docker run --rm --name "$SMOKE_MEM_CONTAINER" \ --memory=64m "$TEST_IMAGE" \ sh -c 'cat /sys/fs/cgroup/memory.max 2>/dev/null || cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null' 2>&1) || true if [[ -z "$mem_limit" ]]; then record_skip "Resource limits" "cgroup memory info not available" return fi local limit_bytes=67108864 # 64 MiB if [[ "$mem_limit" =~ ^[0-9]+$ ]]; then if [[ "$mem_limit" -le $((limit_bytes + 1048576)) ]]; then local limit_mb=$((mem_limit / 1048576)) record_pass "Resource limits" "memory cgroup enforced (${limit_mb}M)" else record_fail "Resource limits" "memory limit not enforced (got ${mem_limit})" fi else record_skip "Resource limits" "unexpected cgroup value: ${mem_limit}" fi } # ── 13. Disk space ─────────────────────────────────────────────────── test_disk_space() { local df_output df_output=$(docker system df 2>/dev/null) || true if [[ -z "$df_output" ]]; then record_fail "Disk space" "docker system df failed" return fi local docker_root used_pct docker_root=$(docker info --format '{{.DockerRootDir}}' 2>/dev/null) || docker_root="/var/lib/docker" used_pct=$(df "$docker_root" 2>/dev/null | tail -1 | awk '{print $5}' | tr -d '%') || used_pct=0 if [[ "$used_pct" -gt 80 ]]; then record_fail "Disk space" "${used_pct}% used (threshold 80%)" else record_pass "Disk space" "${used_pct}% used" fi } # ══════════════════════════════════════════════════════════════════════ # OUTPUT # ══════════════════════════════════════════════════════════════════════ print_tap_header() { echo "TAP version 13" } print_tap_footer() { echo "1..${TOTAL}" echo "# pass ${PASS}" echo "# fail ${FAIL}" echo "# skip ${SKIP}" } print_summary() { local end_time; end_time=$(date +%s) local duration=$(( end_time - START_TIME )) echo "" echo -e "${BOLD}────────────────────────────────────────${RESET}" echo -e "${BOLD}Summary${RESET} Docker Smoke Tests" echo -e " ${GREEN}${PASS} passed${RESET} ${RED}${FAIL} failed${RESET} ${YELLOW}${SKIP} skipped${RESET} (${duration}s)" echo -e "${BOLD}────────────────────────────────────────${RESET}" if [[ $FAIL -eq 0 ]]; then echo -e "${GREEN}${BOLD}All tests passed.${RESET}" else echo -e "${RED}${BOLD}${FAIL} test(s) failed.${RESET}"; fi } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ usage() { cat <