Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<hash>` 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
Expand Down
38 changes: 26 additions & 12 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) \
Expand All @@ -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 ""
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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

# ===========================================================================
Expand Down
158 changes: 158 additions & 0 deletions scripts/plugin-build-extended-image.sh
Original file line number Diff line number Diff line change
@@ -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 [<output-path>] [--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 [<output-path>]"
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:<hash> 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
5 changes: 5 additions & 0 deletions scripts/plugin-resolver.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <string> — render a value as a double-quoted YAML scalar
Expand Down
Loading
Loading