feat(scripts): add canonical docker-doctor linter (Phase E)

Static linter for Dockerfile + docker-compose + .npmrc.docker drift.
Sibling to gitea-doctor. Codifies all 15 invariants from Phase A of
the docker-build-optimization-roadmap so regressions are caught at
PR time, not at build time.

Verified against both pilots:
- learning_ai_clock: PASS (1 expected warning)
- learning_ai_peakpulse: PASS (1 expected warning, pnpm-lock per ADR-0001)
- learning_ai_notes (un-migrated control): FAIL with 6 specific findings

Refs: docker-build-optimization-roadmap.md \xc2\xa7Phase E (E1, E5)
This commit is contained in:
saravanakumardb1 2026-05-27 03:31:43 -07:00
parent dd90f709e1
commit 130883a7db
2 changed files with 276 additions and 0 deletions

View File

@ -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`

207
scripts/docker-doctor.sh Executable file
View File

@ -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