diff --git a/AI.dev/SKILLS/docker-doctor.md b/AI.dev/SKILLS/docker-doctor.md new file mode 100644 index 00000000..89e0a550 --- /dev/null +++ b/AI.dev/SKILLS/docker-doctor.md @@ -0,0 +1,69 @@ +# docker-doctor — Static linter for Docker build hygiene + +Sibling to `gitea-doctor` (`scripts/gitea/doctor.sh`). Detects Dockerfile, compose, and +`.npmrc.docker` drift from the invariants established by +[`docker-build-optimization-roadmap.md`](../../../learning_ai_devops_tools/docs/docker-build-optimization-roadmap.md) +Phase A. + +## When to run + +- **Before every Docker build** (alongside `gitea-doctor`) +- **In CI** on PRs touching `Dockerfile`, `docker-compose*.yml`, `.dockerignore`, `.npmrc.docker` +- **In pre-commit hook** (warning-only at first, error after stabilization) + +## Quick start + +```bash +# Canonical (run from common-plat) +bash scripts/docker-doctor.sh --repo /path/to/repo + +# Per-repo wrapper (preferred) +bash scripts/docker-doctor.sh + +# CI / scripting +bash scripts/docker-doctor.sh --quiet # only print failures +bash scripts/docker-doctor.sh --warn-only # always exit 0 +``` + +## Checks performed + +| # | Check | Severity | Roadmap ref | +| --- | --------------------------------------------------------------------------------------------- | -------- | ----------------- | +| 1 | `.npmrc.docker` uses `${GITEA_NPM_HOST}` placeholder | Error | F4 | +| 2 | `.npmrc.docker` uses `${GITEA_NPM_OWNER}` placeholder | Error | F14 | +| 3 | `.npmrc.docker` uses `${GITEA_NPM_TOKEN}` for `_authToken` | Error | F4 | +| 4 | `.gitignore` covers `.docker-deps/*` | Error | B3 | +| 5 | `.gitignore` covers `*.bak` | Warn | B3 | +| 6 | Dockerfile has `# syntax=docker/dockerfile:1.7` directive | Warn | A2 | +| 7 | Dockerfile base image is approved (`node:22-alpine`/`-slim` or `${BASE_IMAGE}`) | Error | canonical | +| 8 | Dockerfile uses corepack (no `npm install -g pnpm`) | Error | A1 | +| 9 | If Dockerfile COPYs `.npmrc.docker`, it declares `ARG GITEA_NPM_OWNER` + `ARG GITEA_NPM_HOST` | Error | F14 | +| 10 | `.docker-deps/` COPY uses wildcard `COPY .docker-deps* ...` | Error | A5-2, B3 | +| 11 | Web Dockerfile uses glob `COPY web/*.{json,ts,...}` (not enumerated configs) | Error | F11, F13 | +| 12 | Compose healthcheck uses `127.0.0.1`, not `localhost` | Error | F12 | +| 13 | Compose healthcheck has `start_period` | Warn | A9-3 | +| 14 | Compose passes `GITEA_NPM_OWNER` build arg | Warn | F14 | +| 15 | `.dockerignore` does NOT exclude `pnpm-lock.yaml` | Warn | F1 (per ADR-0001) | + +## Exit codes + +- `0` — all checks pass (warnings allowed) +- `1` — one or more error-level checks failed +- `2` — bad invocation / repo not found + +## Sibling: gitea-doctor + +`docker-doctor` and `gitea-doctor` are intentionally separate: + +| Tool | Scope | When to run | +| --------------- | -------------------------------------------- | -------------------------------- | +| `gitea-doctor` | runtime env, token, registry HTTP 200 | Before every build / deploy | +| `docker-doctor` | static analysis of Dockerfile + compose YAML | On every PR touching those files | + +Run both via `make doctor` in repos that have it wired up. + +## Related + +- Roadmap: [`docker-build-optimization-roadmap.md`](../../../learning_ai_devops_tools/docs/docker-build-optimization-roadmap.md) §Phase E +- ADR-0001: [`0001-docker-build-lockfile-policy.md`](../../../learning_ai_devops_tools/docs/adr/0001-docker-build-lockfile-policy.md) +- Sibling script: `learning_ai_common_plat/scripts/gitea/doctor.sh` diff --git a/scripts/docker-doctor.sh b/scripts/docker-doctor.sh new file mode 100755 index 00000000..490b63d0 --- /dev/null +++ b/scripts/docker-doctor.sh @@ -0,0 +1,207 @@ +#!/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