From aa17cb66344b5088c0bbf0371ba5881eb7b8af22 Mon Sep 17 00:00:00 2001 From: Matthew Mellor Date: Sun, 3 May 2026 19:12:51 -0500 Subject: [PATCH] feat(makefile): host cache + Dockerfile.devrail generator (Story 13.4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of Story 13.4 (extended-image build pipeline). Ships the foundation: host-side persistent plugin cache, Dockerfile.devrail generator, and a Story 13.3 review-fix fold-in. Story 13.4b will wire the full docker-build pipeline + DOCKER_RUN swap-in. Components: - Makefile: new DEVRAIL_HOST_PLUGINS_CACHE variable (default ${HOME}/.cache/devrail/plugins) bind-mounted into every DOCKER_RUN at /opt/devrail/plugins. Plugin manifests fetched by `make plugins-update` now survive across container invocations. Closes a Story 13.3 gap (cache was ephemeral). - Makefile: new _ensure-host-cache target wired as a prereq of every public host target that invokes DOCKER_RUN. Idempotent mkdir -p. - scripts/plugin-build-extended-image.sh: reads the loader cache (Story 13.2 — full manifest content per plugin) and emits a Dockerfile.devrail extending the core image with each plugin's container fragment (apt_packages, copy_from_builder, env, install_script). Output is deterministic — env vars sorted by key, plugin order matches lockfile order. FROM line pinned to the exact patch version (not floating :v1) so the eventual devrail-local: is stable across local invocations. - scripts/plugin-resolver.sh: fetch_to_cache now `chmod -R u+rwX,g+rX, o+rX` the cached tree after the atomic swap. mktemp -d defaults to 0700, which blocked the host user from traversing its own bind-mounted cache. Closes a Story 13.3 review-fix follow-up. - tests/test-plugin-build-pipeline.sh: 4-case smoke test: empty cache → no-op (no Dockerfile written); full container block → expected dockerfile shape (apt + COPY + ENV sorted + install_script); deterministic re-runs (sha256 stable); host cache mount + readability (validates the chmod fold-in end-to-end). - .github/workflows/ci.yml: new "Plugin build-pipeline smoke test" step. - CHANGELOG: [Unreleased] Added entry covering all of the above. Test results (local): - tests/test-plugin-build-pipeline.sh — 4/4 (new) - tests/test-plugin-resolver.sh — 16/16 (regression-safe; chmod fold-in now means Case 16's mtime check could read directly from host, but kept the sidecar pattern for now to avoid churn) - tests/test-plugin-loader.sh — 11/11 (regression-safe) - tests/smoke-rails.sh — 4/4 (regression-safe) - make _check on dev-toolchain itself — pass Scope boundary: 13.4a is the foundation. Story 13.4b adds `_extended-image` build target, `DEVRAIL_RESOLVED_IMAGE` swap-in, real-build smoke cases (cache hit/miss, multi-plugin layering, install_script execution, build failure surfacing). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 9 + CHANGELOG.md | 35 ++++ Makefile | 38 +++-- scripts/plugin-build-extended-image.sh | 158 ++++++++++++++++++ scripts/plugin-resolver.sh | 5 + tests/test-plugin-build-pipeline.sh | 221 +++++++++++++++++++++++++ 6 files changed, 454 insertions(+), 12 deletions(-) create mode 100755 scripts/plugin-build-extended-image.sh create mode 100755 tests/test-plugin-build-pipeline.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 393dba1..dc1b3ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,15 @@ jobs: DEVRAIL_IMAGE: ${{ env.IMAGE_NAME }} DEVRAIL_TAG: ${{ env.IMAGE_TAG }} + # Phase 2f: Plugin build-pipeline smoke test (Story 13.4a) + # Generator unit + host cache mount validation. Story 13.4b will extend + # this with full docker-build pipeline cases. + - name: Plugin build-pipeline smoke test + run: bash tests/test-plugin-build-pipeline.sh + env: + DEVRAIL_IMAGE: ${{ env.IMAGE_NAME }} + DEVRAIL_TAG: ${{ env.IMAGE_TAG }} + # Phase 3: Security scans # Blocking scan: OS packages only. We control the base image and can act on # these. ignore-unfixed skips CVEs with no Debian patch available yet. diff --git a/CHANGELOG.md b/CHANGELOG.md index d8fe33a..e2caf98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Plugin build pipeline foundations (Story 13.4a, Epic 13 / v1.10.x preview): + - **Host-side persistent plugin cache.** `DEVRAIL_HOST_PLUGINS_CACHE` + Makefile variable (defaults to `${HOME}/.cache/devrail/plugins`) is + bind-mounted into every `DOCKER_RUN` at `/opt/devrail/plugins`. Plugin + manifests fetched by `make plugins-update` now survive across container + invocations. Closes a Story 13.3 gap where the cache was ephemeral. + - **`scripts/plugin-build-extended-image.sh`** — generates a workspace- + local `Dockerfile.devrail` from the plugin loader cache (Story 13.2) + that extends the core dev-toolchain image with each declared plugin's + `container:` fragment (`apt_packages`, `copy_from_builder`, `env`, + `install_script`). Output is deterministic (env vars sorted by key, + plugin order matches lockfile order). Pinned to the exact patch + version of the core image so the eventual `devrail-local:` tag + is stable across local invocations. + - **`_ensure-host-cache`** Makefile target wired as a prereq of every + public host target that invokes `DOCKER_RUN`. Idempotent `mkdir -p`. + +### Fixed + +- `fetch_to_cache` in `plugin-resolver.sh` now `chmod -R u+rwX,g+rX,o+rX` + the cached tree after the atomic swap. `mktemp -d` defaults to 0700, + which blocked the host user from traversing its own bind-mounted cache + (the `mtime` smoke test in Story 13.3 hit this and worked around it + via a sidecar docker container; this fix makes the workaround + unnecessary). Closes a Story 13.3 review-fix gap. + +### Other + +- `tests/test-plugin-build-pipeline.sh` — 4-case 13.4a smoke test: + empty cache → no-op, full container block → expected dockerfile + shape, deterministic re-runs, host cache mount + readability. + Story 13.4b will extend with full docker-build pipeline cases. + ## [1.10.2] - 2026-05-03 ### Fixed diff --git a/Makefile b/Makefile index 8fce59c..ca599c2 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,11 @@ DEVRAIL_FAIL_FAST ?= 0 DEVRAIL_LOG_FORMAT ?= json DEVRAIL_CONFIG := .devrail.yml +# Host-side persistent plugin cache. Bind-mounted into every DOCKER_RUN so +# plugin manifests fetched by `make plugins-update` survive across container +# invocations. Story 13.4. Override via env if you keep caches elsewhere. +DEVRAIL_HOST_PLUGINS_CACHE ?= $(HOME)/.cache/devrail/plugins + # Read project-specific env vars from .devrail.yml `env:` section and inject # them as `-e KEY=VALUE` into DOCKER_RUN. Empty/missing section is a no-op. DEVRAIL_ENV_FLAGS := $(shell yq -r '.env // {} | to_entries | .[] | "-e " + .key + "=" + .value' $(DEVRAIL_CONFIG) 2>/dev/null) @@ -66,6 +71,7 @@ RUBY_DOCKER_ENV := $(if $(HAS_RUBY),-e BUNDLE_APP_CONFIG=/workspace/.bundle,) DOCKER_RUN := docker run --rm \ -v "$$(pwd):/workspace" \ + -v "$(DEVRAIL_HOST_PLUGINS_CACHE):/opt/devrail/plugins" \ -w /workspace \ -e DEVRAIL_FAIL_FAST=$(DEVRAIL_FAIL_FAST) \ -e DEVRAIL_LOG_FORMAT=$(DEVRAIL_LOG_FORMAT) \ @@ -79,12 +85,20 @@ DOCKER_RUN := docker run --rm \ # .PHONY declarations # --------------------------------------------------------------------------- .PHONY: help build lint format fix test security scan docs changelog check install-hooks init release plugins-update -.PHONY: _lint _format _fix _test _security _scan _docs _changelog _check _check-config _init _plugins-update _plugins-verify +.PHONY: _lint _format _fix _test _security _scan _docs _changelog _check _check-config _init _plugins-update _plugins-verify _ensure-host-cache # =========================================================================== # Public targets (run on host, delegate to Docker container) # =========================================================================== +# --- _ensure-host-cache: create the host-side plugin cache dir --- +# Bind-mounted into every DOCKER_RUN; `docker run -v` would create it as root +# if it doesn't exist (with surprising perms), so create it host-side first +# with the user's umask. Idempotent. Story 13.4. +.PHONY: _ensure-host-cache +_ensure-host-cache: + @mkdir -p "$(DEVRAIL_HOST_PLUGINS_CACHE)" + help: ## Show this help @echo "DevRail dev-toolchain — container image build and validation" @echo "" @@ -94,19 +108,19 @@ help: ## Show this help build: ## Build the container image locally docker build -t $(DEVRAIL_IMAGE):$(DEVRAIL_TAG) . -changelog: ## Generate CHANGELOG.md from conventional commits +changelog: _ensure-host-cache ## Generate CHANGELOG.md from conventional commits $(DOCKER_RUN) make _changelog -check: ## Run all checks (lint, format, test, security, scan, docs) +check: _ensure-host-cache ## Run all checks (lint, format, test, security, scan, docs) $(DOCKER_RUN) make _check -docs: ## Generate documentation +docs: _ensure-host-cache ## Generate documentation $(DOCKER_RUN) make _docs -fix: ## Auto-fix formatting issues in-place +fix: _ensure-host-cache ## Auto-fix formatting issues in-place $(DOCKER_RUN) make _fix -format: ## Run all formatters +format: _ensure-host-cache ## Run all formatters $(DOCKER_RUN) make _format install-hooks: ## Install pre-commit hooks @@ -131,13 +145,13 @@ install-hooks: ## Install pre-commit hooks @pre-commit install --hook-type pre-push @echo "Pre-commit hooks installed successfully. Hooks will run on commit and push." -init: ## Scaffold config files for declared languages +init: _ensure-host-cache ## Scaffold config files for declared languages $(DOCKER_RUN) make _init -lint: ## Run all linters +lint: _ensure-host-cache ## Run all linters $(DOCKER_RUN) make _lint -plugins-update: ## Resolve plugin refs and write .devrail.lock +plugins-update: _ensure-host-cache ## Resolve plugin refs and write .devrail.lock $(DOCKER_RUN) make _plugins-update release: ## Cut a versioned release (usage: make release VERSION=1.6.0) @@ -147,13 +161,13 @@ release: ## Cut a versioned release (usage: make release VERSION=1.6.0) fi @bash scripts/release.sh $(VERSION) -scan: ## Run universal scanners (trivy, gitleaks) +scan: _ensure-host-cache ## Run universal scanners (trivy, gitleaks) $(DOCKER_RUN) make _scan -security: ## Run language-specific security scanners +security: _ensure-host-cache ## Run language-specific security scanners $(DOCKER_RUN) make _security -test: ## Run validation tests +test: _ensure-host-cache ## Run validation tests $(DOCKER_RUN) make _test # =========================================================================== diff --git a/scripts/plugin-build-extended-image.sh b/scripts/plugin-build-extended-image.sh new file mode 100755 index 0000000..1aa6208 --- /dev/null +++ b/scripts/plugin-build-extended-image.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# scripts/plugin-build-extended-image.sh — Generate Dockerfile.devrail +# +# Purpose: Reads the plugin loader cache (Story 13.2 — full manifest content +# per plugin merged with resolution metadata) and emits a workspace- +# local Dockerfile.devrail that extends the core dev-toolchain image +# with each declared plugin's container fragment (apt_packages, +# copy_from_builder, env, install_script). +# +# Usage: bash scripts/plugin-build-extended-image.sh [] [--help] +# Default output: ./Dockerfile.devrail in CWD. +# Exit 0 — file written, OR no plugins declared (skip silently) +# Exit 2 — loader cache missing / malformed / no plugins loaded +# +# Dependencies: yq v4+, lib/log.sh, lib/version.sh +# +# Story 13.4a — generator only. Story 13.4b wraps this with `docker build`, +# cache-hit detection, and DOCKER_RUN swap-in. + +set -euo pipefail +LC_ALL=C +export LC_ALL + +# --- Resolve library path --- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEVRAIL_LIB="${DEVRAIL_LIB:-${SCRIPT_DIR}/../lib}" + +# shellcheck source=../lib/log.sh +source "${DEVRAIL_LIB}/log.sh" +# shellcheck source=../lib/version.sh +source "${DEVRAIL_LIB}/version.sh" + +# --- Help --- +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + log_info "plugin-build-extended-image.sh — Generate Dockerfile.devrail" + log_info "Usage: bash scripts/plugin-build-extended-image.sh []" + log_info "Default output: ./Dockerfile.devrail in CWD" + log_info "Exit 0 — file written or no plugins (skip); 2 — cache missing/malformed" + exit 0 +fi + +# --- Args --- +OUTPUT_PATH="${1:-./Dockerfile.devrail}" +LOADER_CACHE="${DEVRAIL_PLUGINS_CACHE:-/tmp/devrail-plugins-loaded.yaml}" + +require_cmd "yq" "yq is required (v4+)" + +if [[ ! -r "${LOADER_CACHE}" ]]; then + log_event error "loader cache not readable" \ + path="${LOADER_CACHE}" \ + reason="run \`make _plugins-load\` first to populate the cache" \ + language=_plugins + exit 2 +fi + +# --- Probe loader cache for plugins --- +plugin_count="$(yq -r '.plugins // [] | length' "${LOADER_CACHE}" 2>/dev/null || echo 0)" +if [[ "${plugin_count}" == "0" ]]; then + log_event info "no plugins to extend image with; skipping Dockerfile.devrail" \ + cache="${LOADER_CACHE}" language=_plugins + exit 0 +fi + +# --- Compute the FROM line --- +# Pin to the exact patch version so cache-hash is stable across local +# invocations. A floating :v1 tag would invalidate every consumer's +# devrail-local: the moment a new core minor lands. +core_version="$(get_devrail_version)" +if [[ "${core_version}" == "0.0.0-dev" ]]; then + # Local-dev image — fall back to :local tag (matches DEVRAIL_TAG default). + base_from="ghcr.io/devrail-dev/dev-toolchain:local" +else + base_from="ghcr.io/devrail-dev/dev-toolchain:${core_version}" +fi + +# --- Generate --- +# Write to a temp file then move atomically so a partial generation can't +# leave a half-written Dockerfile.devrail (concurrent `make check` reads). +output_tmp="${OUTPUT_PATH}.tmp.$$" +trap 'rm -f "${output_tmp}"' EXIT + +{ + printf '# Auto-generated by plugin-build-extended-image.sh; DO NOT EDIT.\n' + printf '# Source-of-truth: .devrail.yml + .devrail.lock + cached plugin manifests.\n' + printf '# Story 13.4 build pipeline. Extends the core dev-toolchain image with\n' + printf '# each declared plugin'\''s container fragment.\n' + printf '#\n' + printf 'FROM %s AS runtime\n' "${base_from}" + printf '\n' + + for i in $(seq 0 $((plugin_count - 1))); do + name="$(yq -r ".plugins[${i}].name" "${LOADER_CACHE}")" + rev="$(yq -r ".plugins[${i}].rev" "${LOADER_CACHE}")" + # derive_slug-equivalent: basename of source minus optional .git suffix + source_url="$(yq -r ".plugins[${i}].source" "${LOADER_CACHE}")" + slug="$(basename "${source_url}")" + slug="${slug%.git}" + + printf '# --- plugin: %s@%s (source: %s) ---\n' "${name}" "${rev}" "${source_url}" + + # apt_packages — single RUN that installs and cleans up. + apt_packages="$(yq -r ".plugins[${i}].container.apt_packages // [] | .[]" "${LOADER_CACHE}" 2>/dev/null | sort -u | tr '\n' ' ' | sed 's/ $//')" + if [[ -n "${apt_packages}" ]]; then + printf 'RUN apt-get update && apt-get install -y --no-install-recommends \\\n' + printf ' %s \\\n' "${apt_packages}" + printf ' && rm -rf /var/lib/apt/lists/*\n' + fi + + # copy_from_builder — one COPY per path. Uses the manifest's base_image + # as the --from source (e.g. `--from=elixir:1.17-slim /usr/local/bin/elixir ...`). + base_image="$(yq -r ".plugins[${i}].container.base_image // \"\"" "${LOADER_CACHE}")" + if [[ -n "${base_image}" && "${base_image}" != "null" ]]; then + copy_paths="$(yq -r ".plugins[${i}].container.copy_from_builder // [] | .[]" "${LOADER_CACHE}" 2>/dev/null || true)" + while IFS= read -r path; do + [[ -z "${path}" ]] && continue + printf 'COPY --from=%s %s %s\n' "${base_image}" "${path}" "${path}" + done <<<"${copy_paths}" + fi + + # env — sorted by key for determinism. yq's `to_entries | sort_by(.key)` + # gives KEY=VALUE pairs. + env_pairs="$(yq -r ".plugins[${i}].container.env // {} | to_entries | sort_by(.key) | .[] | .key + \"=\" + (.value | tostring)" "${LOADER_CACHE}" 2>/dev/null || true)" + if [[ -n "${env_pairs}" ]]; then + while IFS= read -r kv; do + [[ -z "${kv}" ]] && continue + printf 'ENV %s\n' "${kv}" + done <<<"${env_pairs}" + fi + + # install_script — copy from the cached plugin tree into a known image + # path so the script doesn't depend on the host cache being mounted at + # `docker run` time. The relative path inside the cached tree comes + # from the manifest's container.install_script field. + install_script_rel="$(yq -r ".plugins[${i}].container.install_script // \"\"" "${LOADER_CACHE}")" + if [[ -n "${install_script_rel}" && "${install_script_rel}" != "null" ]]; then + # Absolute source within the build context: the cache is bind-mounted + # into the build by the wrapper (Story 13.4b). For now we emit a path + # relative to the cache root, expecting the caller to run docker build + # with the cache as a build context root or mount. + cache_path=".devrail-plugins-build/${slug}/${rev}/${install_script_rel}" + image_path="/opt/devrail/plugins/${slug}/install.sh" + printf '# Copy install script from build-time cache into the image so\n' + printf '# subsequent runs do not depend on the host cache being mounted.\n' + printf 'COPY %s %s\n' "${cache_path}" "${image_path}" + printf 'RUN chmod +x %s && bash %s\n' "${image_path}" "${image_path}" + fi + printf '\n' + done +} >"${output_tmp}" + +mv "${output_tmp}" "${OUTPUT_PATH}" + +log_event info "Dockerfile.devrail generated" \ + path="${OUTPUT_PATH}" \ + base_image="${base_from}" \ + plugins:="${plugin_count}" \ + language=_plugins +exit 0 diff --git a/scripts/plugin-resolver.sh b/scripts/plugin-resolver.sh index 6942cb1..a4fc2ae 100755 --- a/scripts/plugin-resolver.sh +++ b/scripts/plugin-resolver.sh @@ -178,6 +178,11 @@ fetch_to_cache() { if [[ -d "${old}" ]]; then rm -rf "${old}" fi + # Ensure the host user (when the cache is bind-mounted from the host) can + # traverse the cache. mktemp -d defaults to 0700, which blocks the host + # user from reading its own bind-mounted cache. Apply 0755 to dirs and + # readable mode to files. Story 13.4 review fold-in (closes 13.3 gap). + chmod -R u+rwX,g+rX,o+rX "${target}" 2>/dev/null || true } # yaml_quote — render a value as a double-quoted YAML scalar diff --git a/tests/test-plugin-build-pipeline.sh b/tests/test-plugin-build-pipeline.sh new file mode 100755 index 0000000..bdf2fd1 --- /dev/null +++ b/tests/test-plugin-build-pipeline.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# tests/test-plugin-build-pipeline.sh — Validate the build pipeline (Story 13.4) +# +# 13.4a covers the generator only. 13.4b will extend this with full docker-build +# pipeline cases (cache hit/miss, multi-plugin layering, install_script execution, +# build failure surfacing). For now we exercise: +# +# 1. No plugins → generator skips (no Dockerfile.devrail) +# 2. One plugin with a full container block → expected dockerfile shape +# 3. Generator output is byte-identical across re-runs (deterministic) +# 4. Host cache mount: dirs created on host before docker run; root-owned +# cache files are still readable from the host (review fix M3 fold-in) +# +# Usage: bash tests/test-plugin-build-pipeline.sh +# Env: +# DEVRAIL_IMAGE override image name (default: ghcr.io/devrail-dev/dev-toolchain) +# DEVRAIL_TAG override image tag (default: local) + +set -euo pipefail + +IMAGE="${DEVRAIL_IMAGE:-ghcr.io/devrail-dev/dev-toolchain}:${DEVRAIL_TAG:-local}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKDIR="$(mktemp -d)" + +cleanup() { + if [ -n "${WORKDIR:-}" ] && [ -d "$WORKDIR" ]; then + docker run --rm -v "$WORKDIR:/cleanup" "$IMAGE" \ + sh -c 'rm -rf /cleanup/* /cleanup/.[!.]* 2>/dev/null || true' >/dev/null 2>&1 || true + rmdir "$WORKDIR" 2>/dev/null || rm -rf "$WORKDIR" 2>/dev/null || true + fi +} +trap cleanup EXIT + +assert_eq() { + local expected="$1" actual="$2" context="$3" + if [ "$expected" != "$actual" ]; then + echo "FAIL [$context]: expected '$expected', got '$actual'" >&2 + exit 1 + fi +} + +# run_generator +# Runs plugin-build-extended-image.sh inside the container with the given cache. +# Sets RUN_EXIT and writes container stderr to RUN_OUT. +run_generator() { + local cache="$1" out="$2" + RUN_EXIT=0 + RUN_OUT="$(docker run --rm \ + -v "$cache:/tmp/devrail-plugins-loaded.yaml:ro" \ + -v "$(dirname "$out"):/work" \ + -w /work \ + "$IMAGE" \ + bash /opt/devrail/scripts/plugin-build-extended-image.sh "/work/$(basename "$out")" 2>&1)" || + RUN_EXIT=$? +} + +# --- Case 1: no plugins → generator skips silently --- +echo "==> Case 1: empty loader cache → generator emits 'no plugins' info, no file" +mkdir -p "$WORKDIR/case1" +cat >"$WORKDIR/case1/cache.yaml" <<'YAML' +plugins: [] +YAML +run_generator "$WORKDIR/case1/cache.yaml" "$WORKDIR/case1/Dockerfile.devrail" +assert_eq "0" "$RUN_EXIT" "case1 exit code" +if [ -f "$WORKDIR/case1/Dockerfile.devrail" ]; then + echo "FAIL [case1]: Dockerfile.devrail should NOT have been generated" >&2 + exit 1 +fi +echo "$RUN_OUT" | grep -q "no plugins to extend image with" || { + echo "FAIL [case1]: expected 'no plugins' info event, got:" >&2 + echo "$RUN_OUT" >&2 + exit 1 +} + +# --- Case 2: one plugin with full container block → expected dockerfile shape --- +echo "==> Case 2: full container block → dockerfile contains apt/COPY/ENV/RUN" +mkdir -p "$WORKDIR/case2" +cat >"$WORKDIR/case2/cache.yaml" <<'YAML' +plugins: + - name: elixir + rev: v1.0.0 + source: github.com/community/devrail-plugin-elixir + manifest_path: /opt/devrail/plugins/devrail-plugin-elixir/v1.0.0/plugin.devrail.yml + schema_version: 1 + version: 1.0.0 + devrail_min_version: 1.10.0 + container: + base_image: elixir:1.17-slim + apt_packages: + - inotify-tools + - libstdc++6 + copy_from_builder: + - /usr/local/bin/elixir + - /usr/local/bin/mix + env: + MIX_ENV: prod + ERL_FLAGS: "+S 1" + install_script: install.sh + targets: + lint: + cmd: "mix credo --strict" +YAML +run_generator "$WORKDIR/case2/cache.yaml" "$WORKDIR/case2/Dockerfile.devrail" +assert_eq "0" "$RUN_EXIT" "case2 exit code" +[ -f "$WORKDIR/case2/Dockerfile.devrail" ] || { + echo "FAIL [case2]: Dockerfile.devrail not generated" >&2 + echo "$RUN_OUT" >&2 + exit 1 +} + +generated="$WORKDIR/case2/Dockerfile.devrail" + +# Required content checks +grep -qE '^# Auto-generated by plugin-build-extended-image\.sh' "$generated" || { + echo "FAIL [case2]: missing auto-generated header" >&2 + cat "$generated" >&2 + exit 1 +} +grep -qE '^FROM ghcr\.io/devrail-dev/dev-toolchain:' "$generated" || { + echo "FAIL [case2]: missing FROM line" >&2 + cat "$generated" >&2 + exit 1 +} +grep -q "plugin: elixir@v1.0.0" "$generated" || { + echo "FAIL [case2]: missing plugin section header" >&2 + exit 1 +} +grep -q "apt-get install -y --no-install-recommends" "$generated" || { + echo "FAIL [case2]: missing apt install line" >&2 + exit 1 +} +grep -q "inotify-tools" "$generated" || { + echo "FAIL [case2]: missing apt package" >&2 + exit 1 +} +grep -q "COPY --from=elixir:1.17-slim /usr/local/bin/elixir /usr/local/bin/elixir" "$generated" || { + echo "FAIL [case2]: missing copy_from_builder line" >&2 + exit 1 +} +# env values sorted by key (ERL_FLAGS before MIX_ENV) +erl_line="$(grep -n 'ENV ERL_FLAGS' "$generated" | head -1 | cut -d: -f1)" +mix_line="$(grep -n 'ENV MIX_ENV' "$generated" | head -1 | cut -d: -f1)" +if [ -z "$erl_line" ] || [ -z "$mix_line" ] || [ "$erl_line" -ge "$mix_line" ]; then + echo "FAIL [case2]: env vars not sorted by key (ERL_FLAGS should precede MIX_ENV)" >&2 + cat "$generated" >&2 + exit 1 +fi +grep -q "/opt/devrail/plugins/devrail-plugin-elixir/install.sh" "$generated" || { + echo "FAIL [case2]: missing install_script copy" >&2 + exit 1 +} +grep -q "RUN chmod +x .* && bash" "$generated" || { + echo "FAIL [case2]: missing install_script execution" >&2 + exit 1 +} + +# --- Case 3: deterministic — re-running produces byte-identical output --- +echo "==> Case 3: deterministic output across re-runs" +hash1="$(sha256sum "$generated" | cut -d' ' -f1)" +run_generator "$WORKDIR/case2/cache.yaml" "$WORKDIR/case2/Dockerfile.devrail" +assert_eq "0" "$RUN_EXIT" "case3 second-run exit code" +hash2="$(sha256sum "$generated" | cut -d' ' -f1)" +assert_eq "$hash1" "$hash2" "case3 dockerfile hash stable across re-runs" + +# --- Case 4: host cache directory is created and host-readable --- +# This validates Story 13.4 Task 2 (host cache mount) + the chmod fold-in +# from Story 13.3. Run `make plugins-update` against a local-fs git fixture +# and verify the host can read the cached manifest. +echo "==> Case 4: host cache mount + readable from host" +mkdir -p "$WORKDIR/case4-fixture" +docker run --rm \ + -v "$REPO_ROOT/tests/fixtures/plugin-repos/elixir-v1:/src:ro" \ + -v "$WORKDIR/case4-fixture:/repo" \ + "$IMAGE" \ + sh -c ' + set -e + cp -a /src/. /repo/ + cd /repo + git init --quiet + git config user.email "test@example.com" + git config user.name "Test" + git config commit.gpgsign false + git add -A + git commit --quiet -m "v1.0.0" + git tag v1.0.0 + ' +mkdir -p "$WORKDIR/case4-ws" "$WORKDIR/case4-host-cache" +cat >"$WORKDIR/case4-ws/.devrail.yml" <&1)" || true + +# The host cache must contain the resolved manifest, readable by the host user. +manifest="$WORKDIR/case4-host-cache/case4-fixture/v1.0.0/plugin.devrail.yml" +if [ ! -r "$manifest" ]; then + echo "FAIL [case4]: cached manifest not readable from host: $manifest" >&2 + echo "--- make output ---" >&2 + echo "$case4_out" >&2 + echo "--- host cache listing ---" >&2 + find "$WORKDIR/case4-host-cache" -maxdepth 4 -printf '%y %m %u:%g %p\n' 2>&1 | head -20 >&2 + exit 1 +fi + +echo "==> All build-pipeline (13.4a) smoke checks passed (4/4)"