#!/usr/bin/env bash # docker-doctor — static linter for Dockerfile + compose + .npmrc.docker drift. # # Sibling tool to gitea-doctor. Runs against a repo root and verifies the # invariants established by docker-build-optimization-roadmap.md Phase A. # # Usage: # bash scripts/docker-doctor.sh # check current repo (cwd) # bash scripts/docker-doctor.sh --repo /path # check given repo # bash scripts/docker-doctor.sh --quiet # only print failures # bash scripts/docker-doctor.sh --warn-only # never exit non-zero # # Exit codes: # 0 all checks pass (warnings allowed) # 1 one or more error-level checks failed # 2 bad invocation set -uo pipefail REPO="" QUIET=false WARN_ONLY=false while [ $# -gt 0 ]; do case "$1" in --repo) REPO="${2:-}"; shift ;; --quiet) QUIET=true ;; --warn-only) WARN_ONLY=true ;; -h|--help) sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "Unknown arg: $1" >&2; exit 2 ;; esac shift done REPO="${REPO:-$(pwd)}" if [ ! -d "$REPO" ]; then echo "✗ Not a directory: $REPO" >&2; exit 2 fi cd "$REPO" fail=0 warn_count=0 say() { $QUIET || echo "$@"; } ok() { say " ✓ $1"; } warn() { echo " ⚠ $1"; warn_count=$((warn_count + 1)); } err() { echo " ✗ $1"; fail=1; } say "🐳 docker-doctor — Dockerfile + compose lint ($(basename "$REPO"))" say "" # Discover surfaces DOCKERFILES=$(find . -maxdepth 3 -name 'Dockerfile' -not -path './node_modules/*' -not -path './.git/*' 2>/dev/null | sort) COMPOSE_FILES=$(find . -maxdepth 2 -name 'docker-compose*.yml' -not -path './node_modules/*' 2>/dev/null | sort) if [ -z "$DOCKERFILES" ]; then say " (no Dockerfile found — skipping)" exit 0 fi # ── 1. .npmrc.docker template hygiene (F4, F14) ───────────────── if [ -f .npmrc.docker ]; then if grep -q '\${GITEA_NPM_HOST' .npmrc.docker; then ok ".npmrc.docker uses \${GITEA_NPM_HOST} placeholder" else err ".npmrc.docker: missing \${GITEA_NPM_HOST} placeholder (F4)" fi if grep -q '\${GITEA_NPM_OWNER' .npmrc.docker; then ok ".npmrc.docker uses \${GITEA_NPM_OWNER} placeholder" else err ".npmrc.docker: missing \${GITEA_NPM_OWNER} placeholder (F14) — owner is hardcoded" fi if grep -q '_authToken=\${GITEA_NPM_TOKEN' .npmrc.docker; then ok ".npmrc.docker uses \${GITEA_NPM_TOKEN} placeholder" else err ".npmrc.docker: missing \${GITEA_NPM_TOKEN} placeholder for _authToken" fi else warn ".npmrc.docker not present (acceptable if no @bytelyst/* deps)" fi # ── 2. .gitignore hygiene (B3) ────────────────────────────────── if [ -f .gitignore ]; then if grep -qE '^\.docker-deps/\*?$' .gitignore; then ok ".gitignore covers .docker-deps/" else err ".gitignore missing .docker-deps/* entry (B3)" fi if grep -qE '^\*\.bak$' .gitignore || grep -qE 'package\.json\.bak' .gitignore; then ok ".gitignore covers *.bak (docker-prep artifacts)" else warn ".gitignore: no *.bak rule (docker-prep.sh artifacts may leak)" fi fi # ── 3. Per-Dockerfile checks ──────────────────────────────────── for df in $DOCKERFILES; do say "" say "── $df" # 3a. Syntax directive (enables BuildKit features) if head -1 "$df" | grep -q '^# syntax=docker/dockerfile'; then ok "BuildKit syntax directive present" else warn "missing '# syntax=docker/dockerfile:1.7' directive (A2 cache mounts need it)" fi # 3b. Base image on approved list if grep -qE '^FROM (\$\{BASE_IMAGE[^}]*\}|node:22-alpine|node:22-slim)' "$df"; then ok "approved base image" else err "non-approved base image — must use node:22-alpine, node:22-slim, or \${BASE_IMAGE} ARG" fi # 3c. corepack — not legacy global pnpm install if grep -qE '^RUN[[:space:]]+npm[[:space:]]+install[[:space:]]+-g[[:space:]]+pnpm' "$df"; then err "legacy 'npm install -g pnpm' detected — use corepack (A1)" fi if grep -q 'corepack enable' "$df"; then ok "corepack used for pnpm" fi # 3d. If COPYs .npmrc.docker, must ARG GITEA_NPM_OWNER (F14) if grep -q 'COPY .*\.npmrc\.docker' "$df"; then if grep -qE '^ARG[[:space:]]+GITEA_NPM_OWNER' "$df"; then ok "declares ARG GITEA_NPM_OWNER" else err "COPYs .npmrc.docker but missing 'ARG GITEA_NPM_OWNER' (F14)" fi if grep -qE '^ARG[[:space:]]+GITEA_NPM_HOST' "$df"; then ok "declares ARG GITEA_NPM_HOST" else err "COPYs .npmrc.docker but missing 'ARG GITEA_NPM_HOST' (F14)" fi fi # 3e. .docker-deps must use wildcard COPY (A5-2) if grep -qE 'COPY[[:space:]]+\.docker-deps/ ' "$df"; then err "rigid 'COPY .docker-deps/' — use wildcard 'COPY .docker-deps* /app/.docker-deps/' (A5-2, B3)" fi # 3f. web Dockerfile: glob config copy (F11, F13) if echo "$df" | grep -q 'web/Dockerfile'; then if grep -qE 'COPY[[:space:]]+web/(next\.config|tailwind\.config|postcss\.config)\.[a-z]+' "$df"; then err "enumerated web config COPY — use glob 'COPY web/*.{json,ts,mjs,js,cjs}' (F11, F13)" else if grep -qE 'COPY[[:space:]]+web/\*' "$df"; then ok "web configs copied via glob" else warn "web Dockerfile: could not detect config-file COPY pattern — verify manually" fi fi fi done # ── 4. Compose-file checks ────────────────────────────────────── for cf in $COMPOSE_FILES; do say "" say "── $cf" # 4a. Healthcheck uses 127.0.0.1, not localhost (F12) if grep -qE 'healthcheck:' "$cf"; then if grep -E 'test:|CMD' "$cf" | grep -q 'localhost'; then err "healthcheck uses 'localhost' — must use 127.0.0.1 (F12)" else ok "healthcheck does not use localhost" fi # 4b. start_period present if grep -q 'start_period' "$cf"; then ok "healthcheck has start_period" else warn "healthcheck missing start_period (A9-3) — false fails on cold start" fi fi # 4c. passes GITEA_NPM_OWNER as build arg (F14) if grep -q 'build:' "$cf"; then if grep -q 'GITEA_NPM_OWNER' "$cf"; then ok "compose passes GITEA_NPM_OWNER build arg" else warn "compose: no GITEA_NPM_OWNER build arg (F14) — relies on Dockerfile default" fi fi done # ── 5. .dockerignore (F1, warn until A3 ADR migrates) ─────────── if [ -f .dockerignore ]; then say "" say "── .dockerignore" if grep -qE '^pnpm-lock\.yaml$' .dockerignore; then warn "pnpm-lock.yaml excluded (F1) — acceptable per ADR-0001 until lockfile policy migrates" else ok "pnpm-lock.yaml not excluded" fi fi # ── Summary ───────────────────────────────────────────────────── say "" if [ $fail -eq 0 ]; then say "✅ docker-doctor: PASS ($warn_count warning(s))" exit 0 else echo "❌ docker-doctor: FAIL ($warn_count warning(s))" if $WARN_ONLY; then echo " (--warn-only set: exiting 0)" exit 0 fi exit 1 fi