diff --git a/.gitea/workflows/cicd-checks.yaml b/.gitea/workflows/cicd-checks.yaml index 299ef58..21e7a20 100644 --- a/.gitea/workflows/cicd-checks.yaml +++ b/.gitea/workflows/cicd-checks.yaml @@ -106,6 +106,59 @@ jobs: echo "=== Kernel Tail ===" dmesg | tail -n 120 || true + dockerfile-boundary-check: + name: Dockerfile Runtime Boundary Check + runs-on: ubuntu-act + timeout-minutes: 10 + needs: setup + steps: + - name: Configure registry host resolution + run: | + if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then + echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts + fi + + - name: Ensure CICD image is available + env: + HEAD_SHA: ${{ needs.setup.outputs.head_sha }} + run: | + IMAGE="${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" + if docker image inspect "${IMAGE}" >/dev/null 2>&1; then + echo "Using cached CICD image: ${IMAGE}" + else + echo "${{ secrets.PACKAGE_ACCESS_TOKEN }}" | docker login "http://${GITEA_REGISTRY}" -u "${{ github.actor }}" --password-stdin + pulled=false + for i in 1 2 3; do + echo "Pull attempt ${i}/3 for ${IMAGE}" + if docker pull "${IMAGE}"; then + pulled=true + break + fi + if [ "${i}" -lt 3 ]; then + sleep_seconds=$((5 * i)) + echo "Pull failed; retrying in ${sleep_seconds}s" + sleep "${sleep_seconds}" + fi + done + + if [ "${pulled}" != "true" ]; then + echo "❌ Failed to pull CICD image after 3 attempts: ${IMAGE}" + exit 1 + fi + fi + + - name: Validate runtime Dockerfile boundaries in validation environment + env: + HEAD_SHA: ${{ needs.setup.outputs.head_sha }} + run: | + set -e + docker run --rm --entrypoint /bin/bash "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" -c " + cd /workspace && + bash scripts/check-dockerfile-boundaries.sh + " + + - *failure_diagnostics_step + run-check: name: ${{ matrix.name }} # Run checks across the full act runner pool for maximum parallelism. diff --git a/.gitea/workflows/docker-build-main.yaml b/.gitea/workflows/docker-build-main.yaml index 0f0a44d..4b6e4d6 100644 --- a/.gitea/workflows/docker-build-main.yaml +++ b/.gitea/workflows/docker-build-main.yaml @@ -188,7 +188,7 @@ jobs: RESOLVED_HEAD_SHA="${HEAD_SHA_INPUT:-${HEAD_SHA_FALLBACK}}" echo "head_sha=${RESOLVED_HEAD_SHA}" >> "$GITHUB_OUTPUT" - - name: Minimal checkout for build inputs + - name: Minimal checkout for build and verification inputs env: SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} HEAD_SHA: ${{ steps.meta.outputs.head_sha }} @@ -212,9 +212,45 @@ jobs: GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=no" \ git fetch --depth 1 origin "${HEAD_SHA}" >/dev/null 2>&1 || true - git checkout FETCH_HEAD -- Dockerfile.cicd Dockerfile.cicd-base .dockerignore scripts/compute-cicd-base-hash.sh + git checkout FETCH_HEAD -- \ + .dockerignore \ + Dockerfile.backend \ + Dockerfile.frontend \ + Dockerfile.cicd \ + Dockerfile.cicd-base \ + backend \ + frontend \ + scripts/compute-cicd-base-hash.sh \ + scripts/check-dockerfile-boundaries.sh \ + scripts/verify-deployable-image-purity.sh chmod +x scripts/compute-cicd-base-hash.sh + - name: Verify deployable runtime boundaries + run: | + set -e + bash ./scripts/check-dockerfile-boundaries.sh + + - name: Build and verify deployable runtime image purity + env: + HEAD_SHA: ${{ steps.meta.outputs.head_sha }} + run: | + set -e + + docker build -f Dockerfile.backend \ + -t deployable-backend:"${HEAD_SHA}" . + + docker build -f Dockerfile.frontend \ + --target production \ + -t deployable-frontend:"${HEAD_SHA}" . + + bash ./scripts/verify-deployable-image-purity.sh \ + --image deployable-backend:"${HEAD_SHA}" \ + --profile backend + + bash ./scripts/verify-deployable-image-purity.sh \ + --image deployable-frontend:"${HEAD_SHA}" \ + --profile frontend + - name: Build and push complete CICD image env: PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }} diff --git a/Dockerfile.backend b/Dockerfile.backend index 1dd820a..3328f64 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -19,6 +19,10 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH" # Copy dependency files first for better caching COPY backend/pyproject.toml backend/uv.lock* ./ +# Hatchling resolves the readme path ../README.md relative to the backend +# package root (/app). Create a stub so metadata validation succeeds without +# requiring the file to pass through .dockerignore. +RUN echo '# plex-playlist' > /README.md # Install dependencies RUN uv sync --frozen diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 6e128aa..be5d130 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -6,10 +6,11 @@ WORKDIR /app # Copy package files first for better caching COPY frontend/package*.json ./ +COPY frontend/.yarnrc.yml ./ COPY frontend/yarn.lock* frontend/pnpm-lock.yaml* ./ # Install dependencies -RUN if [ -f yarn.lock ]; then yarn install; \ +RUN if [ -f yarn.lock ]; then corepack enable && corepack yarn install; \ elif [ -f pnpm-lock.yaml ]; then npm install -g pnpm && pnpm install; \ else npm install; fi @@ -23,7 +24,7 @@ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] # Production build stage FROM base AS build -RUN if [ -f yarn.lock ]; then yarn build; \ +RUN if [ -f yarn.lock ]; then corepack yarn build; \ elif [ -f pnpm-lock.yaml ]; then pnpm build; \ else npm run build; fi diff --git a/docs/CICD_MULTI_STAGE_BUILD.md b/docs/CICD_MULTI_STAGE_BUILD.md index b2bdd7a..b370e51 100644 --- a/docs/CICD_MULTI_STAGE_BUILD.md +++ b/docs/CICD_MULTI_STAGE_BUILD.md @@ -148,6 +148,32 @@ jobs: `.gitea/workflows/cicd-tests.yaml` own CI validation and checks. - Main CI never rebuilds the base image locally. +### Runtime Boundary Enforcement + +The main build workflow now enforces separation between validation environments +and deployable runtime artifacts before publishing the complete CICD image. + +- Dockerfile boundary check: + - Script: `scripts/check-dockerfile-boundaries.sh` + - Validates `Dockerfile.backend` and `Dockerfile.frontend` do not reference + CICD image paths (`cicd-base`, `CICD_BASE_IMAGE`, `Dockerfile.cicd*`) and + do not install disallowed CI-only tooling. +- Deployable image purity check: + - Script: `scripts/verify-deployable-image-purity.sh` + - Builds deployable backend and frontend production images and verifies known + CI/development binaries are absent from final runtime artifacts. + - Performs profile-specific metadata checks: + - Backend: Python module import probes and pip metadata checks for + disallowed CI/development packages. + - Frontend: OS package metadata checks (apk/dpkg when available) and + development package directory detection. + +Workflow location: + +- `.gitea/workflows/docker-build-main.yaml` + - `Verify deployable runtime boundaries` + - `Build and verify deployable runtime image purity` + ## Local Development ### Building Base Image diff --git a/docs/DEPLOYABLE_RUNTIME_CONTRACT.md b/docs/DEPLOYABLE_RUNTIME_CONTRACT.md index daa6772..64ddf00 100644 --- a/docs/DEPLOYABLE_RUNTIME_CONTRACT.md +++ b/docs/DEPLOYABLE_RUNTIME_CONTRACT.md @@ -145,12 +145,20 @@ Note: definition. - Covered by: "Disallowed Tooling Classes in Deployable Runtime Images". -## Future Enforcement Hooks (Out of Scope for PP-58) +## Enforcement Hooks -Potential follow-up automation under epic #66: +Current enforcement implemented in CI: + +- Dockerfile target boundary checks: + - Script: `scripts/check-dockerfile-boundaries.sh` + - Workflow: `.gitea/workflows/docker-build-main.yaml` +- Deployable runtime image purity checks: + - Script: `scripts/verify-deployable-image-purity.sh` + - Workflow: `.gitea/workflows/docker-build-main.yaml` + - Checks include binary presence and profile-specific package metadata probes + to detect CI/development tooling leakage. + +Future follow-up automation under epic #66 may expand this with: -- Policy checks validating final image layers do not include disallowed tooling - classes. - Contract tests that assert documented health/startup behavior. -- CI checks that verify Dockerfile target boundaries remain aligned with this - contract. +- Additional policy checks for drift detection and broader runtime compliance. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a8668d1..85a7dd1 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -34,9 +34,34 @@ When changing deployment behavior or image composition, treat Scope boundary: -- This repository separates contract definition from enforcement mechanics. -- CI workflow rewiring and test execution redesign are out of scope for PP-58 - and belong to follow-up work under epic #66. +- Contract definition lives in `DEPLOYABLE_RUNTIME_CONTRACT.md`. +- CI enforcement now includes Dockerfile boundary checks and deployable image + purity checks in `.gitea/workflows/docker-build-main.yaml`. +- Broader workflow redesign and deployment wiring remain out of scope for this + repository's runtime contract document and belong to follow-up work under + epic #66. + +## Runtime vs Validation Enforcement + +Deployable runtime artifacts and validation environments are intentionally +separated. + +- Validation tools (lint/typecheck/test/browser tooling) belong to CICD image + paths (`Dockerfile.cicd-base`, `Dockerfile.cicd`) and CI validation + workflows. +- Deployable runtime paths (`Dockerfile.backend`, `Dockerfile.frontend` + production target) must remain independent of CI-only tooling layers. + +Automated checks: + +- `scripts/check-dockerfile-boundaries.sh` validates deployable Dockerfiles do + not depend on CICD images or install disallowed CI-only tools. +- `scripts/verify-deployable-image-purity.sh` inspects built deployable images + and fails if CI/development binaries or package metadata artifacts are + present. + +These checks run in `.gitea/workflows/docker-build-main.yaml` before publishing +the complete CICD image. ## Quick Start diff --git a/scripts/check-dockerfile-boundaries.sh b/scripts/check-dockerfile-boundaries.sh new file mode 100644 index 0000000..96f2517 --- /dev/null +++ b/scripts/check-dockerfile-boundaries.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +backend_file="Dockerfile.backend" +frontend_file="Dockerfile.frontend" + +declare -a disallowed_references=( + "cicd-base" + "CICD_BASE_IMAGE" + "Dockerfile.cicd" + "Dockerfile.cicd-base" + "plex-playlist-cicd" +) + +declare -a disallowed_runtime_tools=( + "ruff" + "pyright" + "pytest" + "pydoclint" + "xdoctest" + "pre-commit" + "yamllint" + "toml-sort" + "eslint" + "prettier" + "typescript" + "vitest" + "playwright" +) + +check_absent() { + local file="$1" + local token="$2" + + if grep -Eiv '^[[:space:]]*#' "${file}" | grep -Eiq "${token}"; then + echo "❌ Found disallowed token '${token}' in ${file}" >&2 + return 1 + fi + + return 0 +} + +status=0 + +for token in "${disallowed_references[@]}"; do + check_absent "${backend_file}" "${token}" || status=1 + check_absent "${frontend_file}" "${token}" || status=1 +done + +for token in "${disallowed_runtime_tools[@]}"; do + check_absent "${backend_file}" "${token}" || status=1 + check_absent "${frontend_file}" "${token}" || status=1 +done + +if ! grep -Eq '^FROM[[:space:]]+python:3\.14-slim' "${backend_file}"; then + echo "❌ ${backend_file} must use python:3.14-slim as runtime base" >&2 + status=1 +fi + +if ! grep -Eq '^FROM[[:space:]]+nginx:alpine[[:space:]]+AS[[:space:]]+production' "${frontend_file}"; then + echo "❌ ${frontend_file} must use nginx:alpine for production target" >&2 + status=1 +fi + +if [[ "${status}" -ne 0 ]]; then + exit "${status}" +fi + +echo "✅ Dockerfile runtime boundaries are valid" diff --git a/scripts/verify-deployable-image-purity.sh b/scripts/verify-deployable-image-purity.sh new file mode 100644 index 0000000..9797245 --- /dev/null +++ b/scripts/verify-deployable-image-purity.sh @@ -0,0 +1,268 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + scripts/verify-deployable-image-purity.sh --image --profile +USAGE +} + +image_ref="" +profile="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --image) + image_ref="${2:-}" + shift 2 + ;; + --profile) + profile="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +if [[ -z "${image_ref}" || -z "${profile}" ]]; then + usage + exit 2 +fi + +case "${profile}" in + backend) + checks=( + ruff + pyright + pytest + pydoclint + xdoctest + pre-commit + yamllint + toml-sort + eslint + prettier + tsc + vitest + playwright + yarn + npm + node + pnpm + ) + ;; + frontend) + checks=( + ruff + pyright + pytest + pydoclint + xdoctest + pre-commit + yamllint + toml-sort + eslint + prettier + tsc + vitest + playwright + uv + python + python3 + pip + pip3 + node + npm + yarn + pnpm + ) + ;; + *) + echo "Unsupported profile: ${profile}" >&2 + usage + exit 2 + ;; +esac + +tmp_file="$(mktemp)" +trap 'rm -f "${tmp_file}"' EXIT + +printf '%s\n' "${checks[@]}" > "${tmp_file}" + +binary_violations="$(docker run --rm --entrypoint /bin/sh "${image_ref}" -c ' +set -e +while IFS= read -r cmd; do + if command -v "$cmd" >/dev/null 2>&1; then + printf "%s\n" "$cmd" + fi +done +' < "${tmp_file}")" + +if [[ -n "${binary_violations}" ]]; then + echo "❌ Found CI/development tooling binaries in ${profile} image: ${image_ref}" >&2 + echo "${binary_violations}" >&2 + exit 1 +fi + +case "${profile}" in + backend) + backend_python_modules=( + pytest + ruff + pyright + pydoclint + xdoctest + pre_commit + yamllint + toml_sort + playwright + ) + + backend_python_packages=( + pytest + ruff + pyright + pydoclint + xdoctest + pre-commit + yamllint + toml-sort + playwright + ) + + module_violations="$(docker run --rm --entrypoint /bin/sh \ + -e MODULES="${backend_python_modules[*]}" \ + "${image_ref}" -c ' +set -e + +PYTHON_BIN="" +if command -v python3 >/dev/null 2>&1; then + PYTHON_BIN="python3" +elif command -v python >/dev/null 2>&1; then + PYTHON_BIN="python" +fi + +if [ -z "$PYTHON_BIN" ]; then + echo "__missing_python_runtime__" + exit 0 +fi + +for module in ${MODULES}; do + if "$PYTHON_BIN" -c "import importlib.util,sys; sys.exit(0 if importlib.util.find_spec(\"$module\") else 1)" >/dev/null 2>&1; then + printf "%s\n" "$module" + fi +done +')" + + if echo "${module_violations}" | grep -q '__missing_python_runtime__'; then + echo "❌ Backend deployable image is missing python runtime: ${image_ref}" >&2 + exit 1 + fi + + if [[ -n "${module_violations}" ]]; then + echo "❌ Found CI/development Python modules importable in backend image: ${image_ref}" >&2 + echo "${module_violations}" >&2 + exit 1 + fi + + pip_violations="$(docker run --rm --entrypoint /bin/sh \ + -e PACKAGES="${backend_python_packages[*]}" \ + "${image_ref}" -c ' +set -e + +PIP_BIN="" +if command -v pip3 >/dev/null 2>&1; then + PIP_BIN="pip3" +elif command -v pip >/dev/null 2>&1; then + PIP_BIN="pip" +fi + +if [ -z "$PIP_BIN" ]; then + exit 0 +fi + +for package in ${PACKAGES}; do + if "$PIP_BIN" show "$package" >/dev/null 2>&1; then + printf "%s\n" "$package" + fi +done +')" + + if [[ -n "${pip_violations}" ]]; then + echo "❌ Found CI/development Python packages in backend image metadata: ${image_ref}" >&2 + echo "${pip_violations}" >&2 + exit 1 + fi + ;; + frontend) + frontend_os_packages=( + nodejs + npm + yarn + python3 + py3-pip + py3-setuptools + ) + + os_pkg_violations="$(docker run --rm --entrypoint /bin/sh \ + -e PACKAGES="${frontend_os_packages[*]}" \ + "${image_ref}" -c ' +set -e + +if command -v apk >/dev/null 2>&1; then + for package in ${PACKAGES}; do + if apk info -e "$package" >/dev/null 2>&1; then + printf "apk:%s\n" "$package" + fi + done +elif command -v dpkg-query >/dev/null 2>&1; then + for package in ${PACKAGES}; do + if dpkg-query -W -f='"'"'${db:Status-Status}'"'"' "$package" 2>/dev/null | grep -q '^installed$'; then + printf "dpkg:%s\n" "$package" + fi + done +fi +')" + + if [[ -n "${os_pkg_violations}" ]]; then + echo "❌ Found CI/development OS packages in frontend runtime image: ${image_ref}" >&2 + echo "${os_pkg_violations}" >&2 + exit 1 + fi + + dir_violations="$(docker run --rm --entrypoint /bin/sh "${image_ref}" -c ' +set -e + +check_dir_tree() { + local base_dir="$1" + [ -d "$base_dir" ] || return 0 + + find "$base_dir" -maxdepth 5 -type d \ + \( -name node_modules -o -name .venv -o -name site-packages -o -name dist-packages \) \ + 2>/dev/null || true +} + +check_dir_tree /app +check_dir_tree /usr/local/lib +check_dir_tree /usr/lib +check_dir_tree /opt +')" + + if [[ -n "${dir_violations}" ]]; then + echo "❌ Found development package directories in frontend runtime image: ${image_ref}" >&2 + echo "${dir_violations}" >&2 + exit 1 + fi + ;; +esac + +echo "✅ ${profile} image is clean of disallowed CI/development tooling binaries and metadata"