diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 29e33a9..f3294b0 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -17,6 +17,14 @@ on: description: 'Disable buildx cache' required: false default: 'false' + run-postinstall-checks: + description: 'Run feature postInstallCheck commands in built images' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' permissions: contents: read @@ -74,13 +82,38 @@ jobs: echo "$profile" >> profiles-built.txt done + - name: Run post-install contract checks + env: + RUN_POSTINSTALL_CHECKS: ${{ github.event.inputs['run-postinstall-checks'] || 'false' }} + run: | + set -uo pipefail + if [ "$RUN_POSTINSTALL_CHECKS" != 'true' ]; then + echo "Post-install contract checks disabled" + exit 0 + fi + + failed=0 + while read -r profile; do + [ -n "$profile" ] || continue + tag=$(sed -n "/^target \"final-$profile\" /,/^}/p" generated/docker-bake.hcl \ + | awk '/tags = \[/{flag=1; next} flag && /\]/{exit} flag {gsub(/[",]/, ""); gsub(/^[ \t]+|[ \t]+$/, ""); if ($0 != "") {print; exit}}') + if [ -z "$tag" ]; then + echo "No tag found in bake file for $profile; skipping post-install checks" >&2 + continue + fi + echo "Running post-install contract checks for $tag" + docker run --rm --entrypoint /opt/solen/run-postinstall-checks.sh "$tag" || failed=1 + done < profiles-built.txt + + exit "$failed" + - name: Generate SBOM and Trivy scans run: | set -uo pipefail while read -r profile; do [ -n "$profile" ] || continue tag=$(sed -n "/^target \"final-$profile\" /,/^}/p" generated/docker-bake.hcl \ - | sed -n '/tags = \[/,/\]/p' | grep -m1 '"' | tr -d '"') + | awk '/tags = \[/{flag=1; next} flag && /\]/{exit} flag {gsub(/[",]/, ""); gsub(/^[ \t]+|[ \t]+$/, ""); if ($0 != "") {print; exit}}') if [ -z "$tag" ]; then echo "No tag found in bake file for $profile; skipping scans" >&2 continue diff --git a/.github/workflows/ci-validate.yml b/.github/workflows/ci-validate.yml index 235b890..0500678 100644 --- a/.github/workflows/ci-validate.yml +++ b/.github/workflows/ci-validate.yml @@ -41,4 +41,4 @@ jobs: run: python -m pytest tests/ -q - name: Lint - run: ruff check solen-cli + run: ruff check solen-cli tests diff --git a/checksums.json b/checksums.json index 9106bcb..df59297 100644 --- a/checksums.json +++ b/checksums.json @@ -121,7 +121,7 @@ } }, "quarto": { - "pinned_version": "1.8.24", + "pinned_version": "1.10.18", "archive_format": "tar.gz / deb / pkg / msi", "archive_url_template": "https://github.com/quarto-dev/quarto-cli/releases/download/v{version}/quarto-{version}-linux-{arch}.tar.gz", "checksum_asset": "quarto-{version}-checksums.txt", @@ -131,6 +131,10 @@ "1.8.24": { "amd64": "6b83c1c9b6f2ce6454798b42260bd2ee184551d74debe817b8aaf28b09ac22d0", "arm64": "89a97a65a242a5b9b010a9f9978928c1d8e4ac02a558c9cd91a110c3f2611fdd" + }, + "1.10.18": { + "amd64": "afad071b5bd22c02f2d300695743189d3650e0537a53073e654b630cff2b0c73", + "arm64": "f6a07df68e25330b5df34f65d3df66bca605acce3b830c593a58e91884d4cf6c" } } }, @@ -147,14 +151,18 @@ } , "ijava": { - "pinned_version": "1.4.5", + "pinned_version": "1.4.6-pr12", "archive_format": "zip", - "archive_filename": "IJava-latest.zip", - "archive_url_template": "https://github.com/ebpro/IJava/releases/download/v{version}/IJava-latest.zip", + "archive_filename": "IJava-{version}.zip", + "archive_url_template": "https://github.com/ebpro/IJava/releases/download/v{version}/IJava-{version}.zip", "notes": "IJava distribution zip produced by CI (contains install.py and JAR).", "checksums": { "1.4.5": { "amd64": "8336fbcf26cc570fbf33ee52a2e6051d865214355654e32218467db49658f2b5" + }, + "1.4.6-pr12": { + "amd64": "c2fa3bcb3fc22602e7e116bf0f2590dcc521ebf8cecb347885b799919ab6a870", + "arm64": "c2fa3bcb3fc22602e7e116bf0f2590dcc521ebf8cecb347885b799919ab6a870" } } } @@ -175,6 +183,22 @@ } } , + "tinytex": { + "pinned_version": "2026.09", + "archive_format": "tar.gz", + "archive_url_template": "https://github.com/rstudio/tinytex-releases/releases/download/v{version}/installer-unix-v{version}.tar.gz", + "latest_version_cmd": "curl -s https://api.github.com/repos/rstudio/tinytex-releases/releases/latest | jq -r '.tag_name' | sed 's/^v//'", + "notes": "TinyTeX unix installer; same asset for amd64/arm64.", + "checksums": { + "2026.09": { + "amd64": "aaf6e0115b1419bafe45c368ae41f46abe024d21556c989db17707698bf69080", + "arm64": "aaf6e0115b1419bafe45c368ae41f46abe024d21556c989db17707698bf69080", + "x86_64": "aaf6e0115b1419bafe45c368ae41f46abe024d21556c989db17707698bf69080", + "aarch64": "aaf6e0115b1419bafe45c368ae41f46abe024d21556c989db17707698bf69080" + } + } + } + , "miniforge": { "pinned_version": "25.11.0-1", "archive_format": "sh", diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..c3763df --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,237 @@ +# Testing + +This document describes the current testing strategy for Solen and the post-install contract checks added for feature validation. + +## Test layers + +Solen testing is split into several layers: + +1. **Static validation** + - feature schema validation + - profile generation + - version synchronization + - Python unit tests + - linting + +2. **Feature contract tests** + - each feature can declare a `postInstallCheck` + - generated Dockerfiles can run those checks during or after image build + - checks are intended to verify that a feature is usable in the final container + +3. **Feature test scripts** + - features may include `*test*.sh` scripts + - scripts are staged into the image under `/opt/solen/feature-tests//` + - `/opt/solen/run-feature-tests.sh` executes all staged feature tests + +4. **CI build validation** + - canonical builds run in GitHub Actions with Docker BuildKit + - post-install checks can be enabled per build run + +## Local setup + +Create a virtual environment and install the CLI with development dependencies: + +```bash +python3 -m venv .venv +.venv/bin/pip install --upgrade pip +.venv/bin/pip install -e 'solen-cli[dev]' +``` + +Optional, for local mypy checks: + +```bash +.venv/bin/pip install types-PyYAML +``` + +## Static validation + +Run these commands from the repository root: + +```bash +.venv/bin/python -m pytest tests -q +.venv/bin/ruff check solen-cli tests +.venv/bin/solen validate features +.venv/bin/solen versions sync --check +``` + +Generate profiles locally: + +```bash +.venv/bin/solen generate profiles --matrix profiles/matrix --out /tmp/generated-profiles --chain +``` + +For local work, prefer generating profiles into a temporary directory rather than directly into `generated/profiles/`. + +## Unit tests + +The `tests/` directory contains pytest-based validation for generator and feature contract helpers. + +Current relevant tests include: + +- `tests/test_postinstall_checks.py` +- `tests/test_postinstall_check_collection.py` + +Run them with: + +```bash +.venv/bin/python -m pytest tests -q +``` + +## Feature post-install checks + +A feature can declare a post-install check in `feature.json`: + +```json +{ + "id": "java-kernel", + "postInstallCheck": { + "command": "bash -lc '...'", + "description": "Verify Java Jupyter kernelspec is registered" + } +} +``` + +The generated Dockerfile embeds a runner at: + +```text +/opt/solen/run-postinstall-checks.sh +``` + +Behavior: + +- commands are base64-encoded to avoid shell escaping issues +- checks run as `${NB_USER:-jovyan}` +- the runner prefers `runuser`, then `setpriv`, then `su` +- all configured checks are executed +- the runner exits with the number of failed checks + +The behavior is controlled by: + +```dockerfile +ARG RUN_POSTINSTALL_CHECKS="false" +ENV RUN_POSTINSTALL_CHECKS="${RUN_POSTINSTALL_CHECKS}" +``` + +When enabled, the generated image also: + +- runs per-feature post-install checks immediately after each feature install +- runs the full post-install check runner near the end of the install stage + +## Feature test scripts + +Features may include test scripts such as: + +```text +features/java-kernel/test-java-kernel.sh +``` + +Conventions: + +- use bash +- fail fast with `set -euo pipefail` +- verify the runtime artifacts that the feature is supposed to provide +- avoid assumptions about login-shell state where possible +- target the non-root user when relevant + +Example: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +java_bin="$(command -v java || true)" +if [ -z "$java_bin" ]; then + java_bin="/home/${NB_USER:-jovyan}/.sdkman/candidates/java/current/bin/java" +fi + +if [ ! -x "$java_bin" ]; then + echo "ERROR: java runtime not found" >&2 + exit 1 +fi + +"$java_bin" -version +``` + +Generated images stage these scripts into: + +```text +/opt/solen/feature-tests// +``` + +and provide: + +```text +/opt/solen/run-feature-tests.sh +``` + +To run all staged feature tests in a built image: + +```bash +docker run --rm --entrypoint /opt/solen/run-feature-tests.sh +``` + +## CI validation + +### `ci-validate.yml` + +The validation workflow runs static checks, including: + +- `solen validate features` +- profile generation +- devcontainer generation +- version synchronization +- pytest +- ruff over `solen-cli` and `tests` + +### `ci-build.yml` + +The build workflow: + +- generates profiles +- generates `generated/Dockerfile` +- generates `generated/docker-bake.hcl` +- builds the selected profiles +- optionally runs post-install checks in the built images + +The post-install check gate is controlled by the workflow input: + +```yaml +run-postinstall-checks: + description: 'Run feature postInstallCheck commands in built images' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' +``` + +When enabled, CI runs: + +```bash +docker run --rm --entrypoint /opt/solen/run-postinstall-checks.sh +``` + +for each built profile. + +## Local limitations + +Some validations are easier or only possible in CI: + +- canonical image builds require Docker BuildKit and `docker buildx bake` +- feature runtime checks require the built image +- Java-related checks require a Java-enabled profile +- Jupyter/kernel checks require the relevant Python/Jupyter stack + +If local Docker/BuildKit support is unavailable, rely on CI for image-level validation. + +## Roadmap + +Future testing work should extend the current slice with: + +- E2E/BDD scenarios for full profile builds +- automated smoke tests for `quarto-full` +- optional SonarQube quality gates +- Harbor image admission checks +- public registry validation +- richer test reporting for CI artifacts diff --git a/features/build-essentials/install.sh b/features/build-essentials/install.sh index 22e7bf7..395c4ab 100755 --- a/features/build-essentials/install.sh +++ b/features/build-essentials/install.sh @@ -20,7 +20,7 @@ else fi # This feature installs build tools and monitoring utilities. -PKGS="build-essential cmake pkg-config python3-dev libssl-dev libffi-dev git htop lsof strace" +PKGS="build-essential cmake pkg-config python3-dev libssl-dev libffi-dev git htop lsof strace fd-find ripgrep" echo "build-essentials: installing packages: ${PKGS}" if command -v apt_install >/dev/null 2>&1; then @@ -30,4 +30,9 @@ else fi rm -rf /var/lib/apt/lists/* || true +# Ubuntu/Debian package `fd-find` installs `fdfind`, while consumers expect `fd`. +if [ -x /usr/bin/fdfind ] && [ ! -e /usr/local/bin/fd ]; then + ln -sf /usr/bin/fdfind /usr/local/bin/fd || true +fi + echo "build-essentials: done" diff --git a/features/buildah/feature.json b/features/buildah/feature.json index c17de88..754329b 100644 --- a/features/buildah/feature.json +++ b/features/buildah/feature.json @@ -18,7 +18,7 @@ ], "options": {}, "postInstallCheck": { - "command": "buildah --version", + "command": "sh -c 'case \"$(uname -m)\" in x86_64|amd64) exit 0;; *) buildah --version;; esac'", "description": "Verify buildah is installed" }, "documentationURL": "./features/buildah/README.md" diff --git a/features/java-kernel/feature.json b/features/java-kernel/feature.json index 505b187..765c61b 100644 --- a/features/java-kernel/feature.json +++ b/features/java-kernel/feature.json @@ -6,8 +6,8 @@ "options": { "KERNEL_VERSION": { "type": "string", - "description": "Kernel release version to download when using 'release' source (e.g. v1.4.5)", - "default": "v1.4.5" + "description": "Kernel release version to download when using 'release' source (e.g. v1.4.6-pr12)", + "default": "v1.4.6-pr12" }, "ARTIFACTS_BASE_URL": { "type": "string", @@ -21,8 +21,8 @@ }, "JDK_VERSION": { "type": "string", - "description": "JDK version to use (e.g., 17, 21)", - "default": "21" + "description": "JDK version to use (e.g. 21, 25)", + "default": "25" } }, "dependsOn": [ @@ -30,7 +30,7 @@ "jupyter-kernels" ], "postInstallCheck": { - "command": "bash -lc 'if [ -d /usr/local/share/jupyter/kernels/java ]; then exit 0; else jupyter kernelspec list --json | python -c \"import sys,json; ks=json.load(sys.stdin).get(\\\"kernelspecs\\\",{}); sys.exit(0) if \\\"java\\\" in ks else sys.exit(1)\"; fi'" + "command": "bash -lc 'python - </download//IJava-latest.zip if echo "$base_url" | grep -qi "github.com"; then - url="${base_url%/}/download/${KERNEL_VERSION}/IJava-latest.zip" + for candidate_url in \ + "${base_url%/}/download/${version_tag}/IJava-latest.zip" \ + "${base_url%/}/download/${version_tag}/IJava-${version_tag}.zip" \ + "${base_url%/}/download/${version_tag}/IJava-${version_raw}.zip" + do + if curl -fsIL --max-time 20 "$candidate_url" >/dev/null 2>&1; then + url="$candidate_url" + break + fi + done else - # fallback to a tarball named java-kernel-.tar.gz url="${base_url%/}/java-kernel-${KERNEL_VERSION}.tar.gz" fi fi @@ -83,6 +95,7 @@ install_from_release() { fi tmpdir=$(mktemp -d) + chmod 755 "$tmpdir" trap 'rm -rf "$tmpdir"' EXIT echo "java-kernel: downloading $url" @@ -141,6 +154,31 @@ install_from_release() { ;; esac + chmod -R a+rX "$tmpdir" 2>/dev/null || true + + # Prefer the release-provided installer when present (IJava >= 1.4.6-pr*) + install_py=$(find "$tmpdir" -type f -name install.py | head -n1 || true) + if [[ -n "$install_py" ]]; then + python_bin="${CONDA_DIR}/bin/python" + if [[ ! -x "$python_bin" ]]; then + python_bin=$(command -v python3 || true) + fi + if [[ -z "$python_bin" ]]; then + echo "java-kernel: no Python interpreter available to run install.py" >&2 + return 1 + fi + + install_script_dir=$(dirname "$install_py") + if [[ "$(id -u)" == "0" ]]; then + su - "${NB_USER:-jovyan}" -c "cd '$install_script_dir' && '$python_bin' '$install_py' --sys-prefix --replace" + else + (cd "$install_script_dir" && "$python_bin" "$install_py" --sys-prefix --replace) + fi + chown -R ${NB_UID:-1001}:${NB_GID:-1001} "${CONDA_DIR}/share/jupyter/kernels/java" >/dev/null 2>&1 || true + echo "java-kernel: installed kernelspec via install.py" + return 0 + fi + # Expect jar under dist/ or lib/ or root jar=$(find "$tmpdir" -type f \( -iname '*ijava*.jar' -o -iname '*kernel*.jar' \) | head -n1 || true) if [[ -z "$jar" ]]; then @@ -151,7 +189,6 @@ install_from_release() { return 1 fi - CONDA_DIR=${CONDA_DIR:-/home/${NB_USER:-jovyan}/miniforge3} dest="${CONDA_DIR}/share/jupyter/kernels/java" mkdir -p "$dest" cp "$jar" "$dest/ijava.jar" diff --git a/features/java-kernel/test-java-kernel.sh b/features/java-kernel/test-java-kernel.sh new file mode 100755 index 0000000..416716c --- /dev/null +++ b/features/java-kernel/test-java-kernel.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "Running java-kernel feature tests" + +NB_USER="${NB_USER:-jovyan}" + +java_bin="$(command -v java || true)" +if [ -z "$java_bin" ]; then + java_bin="/home/${NB_USER}/.sdkman/candidates/java/current/bin/java" +fi + +if [ ! -x "$java_bin" ]; then + echo "ERROR: java runtime not found" >&2 + exit 1 +fi + +"$java_bin" -version + +kernel_dir="/usr/local/share/jupyter/kernels/java" +if [ -d "$kernel_dir" ]; then + echo "Found IJava kernelspec at $kernel_dir" +else + jupyter_bin="$(command -v jupyter || true)" + if [ -z "$jupyter_bin" ]; then + for candidate in "/home/${NB_USER}/miniforge3/bin/jupyter" "/usr/local/bin/jupyter"; do + if [ -x "$candidate" ]; then + jupyter_bin="$candidate" + break + fi + done + fi + + if [ -z "$jupyter_bin" ]; then + echo "ERROR: IJava kernelspec not found and jupyter CLI is unavailable" >&2 + exit 1 + fi + + "$jupyter_bin" kernelspec list --json | python3 -c ' +import json +import sys + +kernels = json.load(sys.stdin) +if "java" not in kernels: + raise SystemExit("ERROR: java kernelspec not registered") +' +fi + +echo "java-kernel feature tests passed" diff --git a/features/kubernetes-client/install.sh b/features/kubernetes-client/install.sh index 27daa1a..21bb913 100755 --- a/features/kubernetes-client/install.sh +++ b/features/kubernetes-client/install.sh @@ -37,36 +37,36 @@ case "$arch_raw" in esac # Resolve versions from artefacts -if [ -f "${PWD}/artefacts/kubernetes-client/versions.json" ] || [ -f "${PWD}/artefacts/versions.json" ] || [ -f /tmp/versions.json ]; then - resolve_version() { - local tool="$1" v="" - # prefer centralized resolver - if command -v fh_resolve_version >/dev/null 2>&1; then - v=$(fh_resolve_version "$tool" || true) - if [ -n "$v" ]; then - echo "$v"; return 0 - fi +resolve_version() { + local tool="$1" v="" + # prefer centralized resolver + if command -v fh_resolve_version >/dev/null 2>&1; then + v=$(fh_resolve_version "$tool" || true) + if [ -n "$v" ]; then + echo "$v"; return 0 fi - if [ -f "${PWD}/artefacts/kubernetes-client/versions.json" ]; then - v=$(jq -r --arg t "$tool" '.tools[$t] // empty' "${PWD}/artefacts/kubernetes-client/versions.json" 2>/dev/null || true) - [ -n "$v" ] && { echo "$v"; return 0; } - fi - if [ -f "${PWD}/artefacts/versions.json" ]; then - v=$(jq -r --arg t "$tool" '.tools[$t] // empty' "${PWD}/artefacts/versions.json" 2>/dev/null || true) - [ -n "$v" ] && { echo "$v"; return 0; } - fi - if [ -f /tmp/versions.json ]; then - v=$(jq -r --arg t "$tool" '.tools[$t] // empty' /tmp/versions.json 2>/dev/null || true) - [ -n "$v" ] && { echo "$v"; return 0; } - fi - echo "" - } + fi + if [ -f "${PWD}/artefacts/kubernetes-client/versions.json" ]; then + v=$(jq -r --arg t "$tool" '.tools[$t] // empty' "${PWD}/artefacts/kubernetes-client/versions.json" 2>/dev/null || true) + [ -n "$v" ] && { echo "$v"; return 0; } + fi + if [ -f "${PWD}/artefacts/versions.json" ]; then + v=$(jq -r --arg t "$tool" '.tools[$t] // empty' "${PWD}/artefacts/versions.json" 2>/dev/null || true) + [ -n "$v" ] && { echo "$v"; return 0; } + fi + if [ -f /tmp/versions.json ]; then + v=$(jq -r --arg t "$tool" '.tools[$t] // empty' /tmp/versions.json 2>/dev/null || true) + [ -n "$v" ] && { echo "$v"; return 0; } + fi + echo "" +} - KUBECTL_VERSION=$(resolve_version "kubectl") - HELM_VERSION=$(resolve_version "helm") - KUSTOMIZE_VERSION=$(resolve_version "kustomize") -else - echo "kubernetes-client: no versions.json found, skipping" >&2 +KUBECTL_VERSION=$(resolve_version "kubectl") +HELM_VERSION=$(resolve_version "helm") +KUSTOMIZE_VERSION=$(resolve_version "kustomize") + +if [ -z "${KUBECTL_VERSION}" ] && [ -z "${HELM_VERSION}" ] && [ -z "${KUSTOMIZE_VERSION}" ]; then + echo "kubernetes-client: no versions resolved, skipping" >&2 exit 0 fi diff --git a/features/kubernetes-dev/install.sh b/features/kubernetes-dev/install.sh index 5bdb4f6..072db68 100755 --- a/features/kubernetes-dev/install.sh +++ b/features/kubernetes-dev/install.sh @@ -28,24 +28,8 @@ LOCAL_BIN="${HOME_DIR}/bin" mkdir -p "${LOCAL_BIN}" # Resolve k9s version -if [ -f "${PWD}/artefacts/kubernetes-dev/versions.json" ] || [ -f "${PWD}/artefacts/versions.json" ] || [ -f /tmp/versions.json ]; then - resolve_version() { - local tool="$1" v="" - if [ -f "${PWD}/artefacts/kubernetes-dev/versions.json" ]; then - v=$(jq -r --arg t "$tool" '.tools[$t] // empty' "${PWD}/artefacts/kubernetes-dev/versions.json" 2>/dev/null || true) - [ -n "$v" ] && { echo "$v"; return 0; } - fi - if [ -f "${PWD}/artefacts/versions.json" ]; then - v=$(jq -r --arg t "$tool" '.tools[$t] // empty' "${PWD}/artefacts/versions.json" 2>/dev/null || true) - [ -n "$v" ] && { echo "$v"; return 0; } - fi - if [ -f /tmp/versions.json ]; then - v=$(jq -r --arg t "$tool" '.tools[$t] // empty' /tmp/versions.json 2>/dev/null || true) - [ -n "$v" ] && { echo "$v"; return 0; } - fi - echo "" - } - +resolve_version() { + local tool="$1" v="" # prefer centralized resolver if command -v fh_resolve_version >/dev/null 2>&1; then v=$(fh_resolve_version "$tool" || true) @@ -53,9 +37,24 @@ if [ -f "${PWD}/artefacts/kubernetes-dev/versions.json" ] || [ -f "${PWD}/artefa echo "$v"; return 0 fi fi - K9S_VERSION=$(resolve_version "k9s") -else - echo "kubernetes-dev: no versions.json found, skipping" >&2 + if [ -f "${PWD}/artefacts/kubernetes-dev/versions.json" ]; then + v=$(jq -r --arg t "$tool" '.tools[$t] // empty' "${PWD}/artefacts/kubernetes-dev/versions.json" 2>/dev/null || true) + [ -n "$v" ] && { echo "$v"; return 0; } + fi + if [ -f "${PWD}/artefacts/versions.json" ]; then + v=$(jq -r --arg t "$tool" '.tools[$t] // empty' "${PWD}/artefacts/versions.json" 2>/dev/null || true) + [ -n "$v" ] && { echo "$v"; return 0; } + fi + if [ -f /tmp/versions.json ]; then + v=$(jq -r --arg t "$tool" '.tools[$t] // empty' /tmp/versions.json 2>/dev/null || true) + [ -n "$v" ] && { echo "$v"; return 0; } + fi + echo "" +} + +K9S_VERSION=$(resolve_version "k9s") +if [ -z "${K9S_VERSION}" ]; then + echo "kubernetes-dev: no k9s version resolved, skipping" >&2 exit 0 fi diff --git a/features/my-utils/feature.json b/features/my-utils/feature.json index 2f5d186..c8a31d6 100644 --- a/features/my-utils/feature.json +++ b/features/my-utils/feature.json @@ -7,7 +7,7 @@ "maintainer": "local", "license": "MIT", "postInstallCheck": { - "command": "test -x /home/jovyan/.local/bin/gitpull", + "command": "test -x /home/jovyan/.local/bin/gitpull.sh", "description": "Verify my-utils scripts are installed and executable" }, "documentationURL": "./features/my-utils/README.md" diff --git a/features/prompt-helpers/feature.json b/features/prompt-helpers/feature.json index 4540aa7..b07d640 100644 --- a/features/prompt-helpers/feature.json +++ b/features/prompt-helpers/feature.json @@ -13,7 +13,7 @@ ], "options": {}, "postInstallCheck": { - "command": "test -x /usr/local/bin/gitstatusd", + "command": "test -x /usr/local/bin/gitstatusd || test -x ${HOME}/.local/share/gitstatus/gitstatusd", "description": "Verify gitstatusd binary is installed and executable" }, "documentationURL": "./features/prompt-helpers/README.md", diff --git a/features/prompt-helpers/install.sh b/features/prompt-helpers/install.sh index 377d00e..67a038b 100755 --- a/features/prompt-helpers/install.sh +++ b/features/prompt-helpers/install.sh @@ -64,9 +64,8 @@ echo "prompt-helpers: preparing to install gitstatusd ${GITSTATUS_VERSION}" # hard failures during multi-arch builds. probe_arch=$(uname -m) case "$probe_arch" in - x86_64) probe_token="amd64" ;; - aarch64) probe_token="aarch64" ;; - arm64) probe_token="aarch64" ;; + x86_64|X86_64|amd64) probe_token="x86_64" ;; + aarch64|arm64) probe_token="aarch64" ;; *) probe_token="$probe_arch" ;; esac probe_url="https://github.com/romkatv/gitstatus/releases/download/v${GITSTATUS_VERSION}/gitstatusd-linux-${probe_token}.tar.gz" @@ -78,7 +77,7 @@ if curl -sfI "$probe_url" >/dev/null 2>&1; then "romkatv/gitstatus" \ "gitstatusd" \ "${GITSTATUS_VERSION}" \ - "${HOME_DIR}/.cache/gitstatus" \ + "${HOME_DIR}/.local/share/gitstatus" \ "gitstatusd-linux-{arch}.tar.gz" \ "true" else @@ -90,14 +89,14 @@ else fi # Set ownership -chown -R "${NB_UID}":"${NB_GID}" "${HOME_DIR}/.cache/gitstatus" || true +chown -R "${NB_UID}":"${NB_GID}" "${HOME_DIR}/.local/share/gitstatus" || true echo "prompt-helpers: done" # If the helper didn't place an executable at the expected path, try a resilient # fallback: search cached archives, list their contents and extract any matching # gitstatus/gitstatusd binary into the cache location. -TARGET_DIR="${HOME_DIR}/.cache/gitstatus" +TARGET_DIR="${HOME_DIR}/.local/share/gitstatus" TARGET_BIN="${TARGET_DIR}/gitstatusd" mkdir -p "${TARGET_DIR}" || true if [ ! -x "${TARGET_BIN}" ]; then @@ -132,3 +131,8 @@ if [ ! -x "${TARGET_BIN}" ]; then echo "prompt-helpers: ❌ gitstatusd still not found after fallback" fi fi + +if [ -x "${TARGET_BIN}" ]; then + ln -sf "${TARGET_BIN}" /usr/local/bin/gitstatusd || true + chmod +x /usr/local/bin/gitstatusd || true +fi diff --git a/features/quarto-chromium/feature.json b/features/quarto-chromium/feature.json index 2c622ee..cf85f69 100644 --- a/features/quarto-chromium/feature.json +++ b/features/quarto-chromium/feature.json @@ -19,8 +19,8 @@ "email": "emmanuel.bruno@univ-tln.fr" }, "postInstallCheck": { - "command": "sh -c 'command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1'", - "description": "Verify Chromium is available for Quarto rendering" + "command": "chromium_bin=\"$(command -v chromium || command -v chromium-browser || command -v chrome-headless-shell || command -v headless_shell || true)\"\nif [ -z \"${chromium_bin}\" ]; then\n echo \"quarto-chromium: CHECK FAIL - no chromium/chromium-browser/chrome-headless-shell on PATH\" >&2\n exit 1\nfi\nerr_file=\"$(mktemp)\"\nbin_base=\"$(basename \"${chromium_bin}\")\"\ncase \"${bin_base}\" in\n chrome-headless-shell|headless_shell)\n out=\"$(timeout 60 \"${chromium_bin}\" --disable-gpu --dump-dom about:blank 2>\"${err_file}\")\" && rc=0 || rc=$?\n ;;\n *)\n out=\"$(timeout 60 \"${chromium_bin}\" --headless --disable-gpu --dump-dom about:blank 2>\"${err_file}\")\" && rc=0 || rc=$?\n ;;\nesac\nif [ \"${rc}\" -ne 0 ]; then\n echo \"quarto-chromium: CHECK FAIL - headless chromium exited ${rc}\" >&2\n cat \"${err_file}\" >&2\n rm -f \"${err_file}\"\n exit 1\nfi\nrm -f \"${err_file}\"\nif [ -z \"${out}\" ]; then\n echo \"quarto-chromium: CHECK FAIL - headless chromium produced no output\" >&2\n exit 1\nfi\necho \"quarto-chromium: check OK (headless chromium rendered via ${chromium_bin})\"", + "description": "Functionally verify headless Chromium starts and renders (CI-safe shim + shared libraries), not just that the binary exists" }, "documentationURL": "./features/quarto-chromium/README.md" } diff --git a/features/quarto-chromium/install.sh b/features/quarto-chromium/install.sh index c5d5d38..4a6876a 100755 --- a/features/quarto-chromium/install.sh +++ b/features/quarto-chromium/install.sh @@ -17,20 +17,61 @@ HOME_DIR="/home/${NB_USER}" echo "quarto-chromium: installing Chromium runtime" +# Shared libraries required by the Chromium binary that 'quarto install +# chromium' downloads (verified via ldd against the bundled build: +# libnss3, libnspr4, libatk*, libatk-bridge*, libcups, libdrm, +# libxkbcommon, libXcomposite, libXdamage, libXfixes, libXrandr, libgbm, +# libpango, libcairo, libgtk-3, libasound, libatspi, libxshmfence). +# Without these the binary dies at startup with +# "error while loading shared libraries: libnss3.so", which Quarto's +# server-side render reports only as a bare "ERROR: AssertionError:". +# (noble 24.04 t64 package names; verified with apt-get install --dry-run.) +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y --no-install-recommends \ + libnss3 \ + libnspr4 \ + libatk1.0-0t64 \ + libatk-bridge2.0-0t64 \ + libcups2t64 \ + libdrm2 \ + libxkbcommon0 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxrandr2 \ + libgbm1 \ + libpango-1.0-0 \ + libcairo2 \ + libasound2t64 \ + libatspi2.0-0t64 \ + libgtk-3-0t64 \ + libxshmfence1 +rm -rf /var/lib/apt/lists/* + # Use the available Quarto binary (search PATH or common /opt/quarto/* location) TMP_SCRIPT="/tmp/quarto-chromium-install-${NB_USER}.sh" cat > "${TMP_SCRIPT}" <<'BASH' #!/usr/bin/env bash set -euo pipefail -# locate quarto binary, prefer one on PATH, fallback to /opt/quarto/*/bin/quarto +# locate quarto binary, prefer PATH, then common install locations QUARTO_BIN=$(command -v quarto || true) if [ -z "${QUARTO_BIN}" ]; then - QUARTO_BIN=$(ls /opt/quarto/*/bin/quarto 2>/dev/null | head -n1 || true) + for c in "$HOME/.local/bin/quarto" "$HOME/miniforge3/bin/quarto" /usr/local/bin/quarto /opt/quarto/*/bin/quarto; do + if [ -x "$c" ]; then + QUARTO_BIN="$c" + break + fi + done fi if [ -n "${QUARTO_BIN}" ] && [ -x "${QUARTO_BIN}" ]; then - "${QUARTO_BIN}" install chromium --no-prompt || true + CI=true "${QUARTO_BIN}" install --no-prompt --log "${HOME}/.quarto-chromium-install.log" --log-level debug chrome-headless-shell \ + || CI=true "${QUARTO_BIN}" install chrome-headless-shell --no-prompt \ + || CI=true "${QUARTO_BIN}" install --no-prompt --log "${HOME}/.quarto-chromium-install.log" --log-level debug chromium \ + || CI=true "${QUARTO_BIN}" install chromium --no-prompt \ + || true else - echo "quarto binary not found; skipping 'quarto install chromium'" >&2 + echo "quarto binary not found; skipping 'quarto install chromium'" >&2 fi BASH @@ -38,6 +79,199 @@ chmod +x "${TMP_SCRIPT}" su - ${NB_USER} -s /bin/bash -c "${TMP_SCRIPT}" || true rm -f "${TMP_SCRIPT}" +find_chromium_bin() { + local c + for c in \ + "${HOME_DIR}/.quarto/bin/chromium" \ + "${HOME_DIR}/.quarto/bin/chromium-browser" \ + "${HOME_DIR}/.quarto/bin/chrome" \ + "${HOME_DIR}/.quarto/bin/chrome-headless-shell" \ + "${HOME_DIR}/.quarto/bin/headless_shell" \ + "${HOME_DIR}/.local/share/quarto/chromium/linux-"*/chrome-linux/chrome \ + "${HOME_DIR}/.local/share/quarto/chrome-headless-shell/linux-"*/chrome-headless-shell \ + "${HOME_DIR}/.local/share/quarto-chromium/chromium" \ + "${HOME_DIR}/.local/share/quarto-chromium/chromium-browser" \ + "${HOME_DIR}/.local/share/quarto-chromium/chrome" \ + /usr/bin/chromium \ + /usr/bin/chromium-browser \ + /usr/local/bin/chromium \ + /usr/local/bin/chromium-browser + do + if [ -x "$c" ]; then + printf '%s\n' "$c" + return 0 + fi + done + + local search_dir found candidate + for search_dir in \ + "${HOME_DIR}/.local/share/quarto/chromium" \ + "${HOME_DIR}/.local/share/quarto" \ + "${HOME_DIR}/.quarto" \ + "${HOME_DIR}/.local/share/quarto-chromium" \ + /opt/quarto \ + /usr/local/bin \ + /usr/bin \ + /usr/lib \ + "${HOME_DIR}/.cache" \ + /root/.quarto \ + /root/.cache \ + /tmp \ + /var/cache + do + [ -d "${search_dir}" ] || continue + found=$( + find "${search_dir}" \( -type f -o -type l \) \( -name chrome -o -name chrome-headless-shell -o -name chromium -o -name chromium-browser -o -name headless_shell \) 2>/dev/null | + while IFS= read -r candidate; do + if [ -x "${candidate}" ]; then + printf '%s\n' "${candidate}" + break + fi + done + ) + if [ -n "${found}" ]; then + printf '%s\n' "${found}" + return 0 + fi + done + + return 1 +} + +# Wrap the real Chromium binary IN PLACE with a CI-safe shim. +# +# Why: courseware render CI runs the container as root in a restricted +# docker context. Quarto's own browser spawn (criClient in quarto.js) +# passes --no-sandbox --disable-gpu but NOT --disable-dev-shm-usage, and +# the default 64 MB /dev/shm is too small for headless Chrome, so the +# Chromium child dies at startup and Quarto's error path reports a bare +# "ERROR: AssertionError:" instead of the real cause. The shim bakes in +# the CI-safe flags for every invocation. +# +# In-place wrapping keeps all of Quarto's discovery routes on the shim: +# the /usr/local/bin + ~/.local/bin symlinks created below, and Quarto's +# own install dir (~/.local/share/quarto/chromium/...), all resolve to +# this same file. +wrap_chromium_shim() { + local bin="$1" + local real bin_name resolved + resolved="$(readlink -f "${bin}" 2>/dev/null || true)" + if [ -n "${resolved}" ]; then + bin="${resolved}" + fi + real="${bin}.real" + bin_name="$(basename "${bin}")" + + # Idempotent: a shim is already in place. + if [ -e "${bin}" ] && head -n 5 "${bin}" 2>/dev/null | grep -q 'solen-chromium-shim'; then + echo "quarto-chromium: shim already in place at ${bin}" + return 0 + fi + + if [ -e "${bin}" ]; then + # Fresh binary at the public path (first run, or 'quarto install + # chromium' replaced the shim on a re-run): move it aside, overwriting + # any stale .real. + mv -f "${bin}" "${real}" + elif [ ! -e "${real}" ]; then + echo "quarto-chromium: ERROR: no Chromium binary at ${bin} to wrap" >&2 + return 1 + fi + # else: public path gone but .real remains — fall through and re-write + # the shim pointing at it. + + cat > "${bin}" </dev/null || echo "\$0")" +exec "\$(dirname "\$self")/${bin_name}.real" --no-sandbox --disable-gpu --disable-dev-shm-usage "\$@" +SHIM + chmod 0755 "${bin}" + chown "${NB_UID}":"${NB_GID}" "${bin}" "${real}" || true + echo "quarto-chromium: installed CI-safe shim at ${bin} (original: ${real})" +} + +CHROMIUM_BIN="$(find_chromium_bin || true)" +if [ -n "${CHROMIUM_BIN}" ]; then + echo "quarto-chromium: found Chromium binary at ${CHROMIUM_BIN}" + + PERSIST_DIR="${HOME_DIR}/.local/share/quarto-chromium" + if [[ "${CHROMIUM_BIN}" == *"/.cache/*" || "${CHROMIUM_BIN}" == /root/* || "${CHROMIUM_BIN}" == /tmp/* || "${CHROMIUM_BIN}" == /var/cache/* ]]; then + src_dir="$(dirname "${CHROMIUM_BIN}")" + src_name="$(basename "${src_dir}")" + bin_name="$(basename "${CHROMIUM_BIN}")" + mkdir -p "${PERSIST_DIR}" + + case "${src_name}" in + tmp|var|cache|root|home|opt|usr|etc) + cp -a "${CHROMIUM_BIN}" "${PERSIST_DIR}/${bin_name}" || true + if [ -x "${PERSIST_DIR}/${bin_name}" ]; then + CHROMIUM_BIN="${PERSIST_DIR}/${bin_name}" + echo "quarto-chromium: copied Chromium binary to ${CHROMIUM_BIN}" + else + echo "quarto-chromium: WARNING: failed to copy Chromium binary to ${PERSIST_DIR}/${bin_name}" >&2 + fi + ;; + *) + dest_dir="${PERSIST_DIR}/${src_name}" + if [ -d "${dest_dir}" ]; then + rm -rf "${dest_dir}" + fi + cp -a "${src_dir}" "${dest_dir}" || true + if [ -x "${dest_dir}/${bin_name}" ]; then + CHROMIUM_BIN="${dest_dir}/${bin_name}" + echo "quarto-chromium: copied Chromium runtime to ${CHROMIUM_BIN}" + else + echo "quarto-chromium: WARNING: copied Chromium runtime but expected binary not found at ${dest_dir}/${bin_name}" >&2 + fi + ;; + esac + fi + + chmod +x "${CHROMIUM_BIN}" || true + chown "${NB_UID}":"${NB_GID}" "${CHROMIUM_BIN}" || true + if [ -d "${PERSIST_DIR}" ]; then + chown -R "${NB_UID}":"${NB_GID}" "${PERSIST_DIR}" || true + fi + mkdir -p "${HOME_DIR}/.local/bin" || true + ln -sf "${CHROMIUM_BIN}" /usr/local/bin/chromium || true + ln -sf "${CHROMIUM_BIN}" /usr/local/bin/chromium-browser || true + ln -sf "${CHROMIUM_BIN}" /usr/local/bin/chrome-headless-shell || true + ln -sf "${CHROMIUM_BIN}" /usr/local/bin/headless_shell || true + ln -sf "${CHROMIUM_BIN}" "${HOME_DIR}/.local/bin/chromium" || true + ln -sf "${CHROMIUM_BIN}" "${HOME_DIR}/.local/bin/chromium-browser" || true + ln -sf "${CHROMIUM_BIN}" "${HOME_DIR}/.local/bin/chrome-headless-shell" || true + ln -sf "${CHROMIUM_BIN}" "${HOME_DIR}/.local/bin/headless_shell" || true + chown "${NB_UID}":"${NB_GID}" "${HOME_DIR}/.local/bin/chromium" "${HOME_DIR}/.local/bin/chromium-browser" "${HOME_DIR}/.local/bin/chrome-headless-shell" "${HOME_DIR}/.local/bin/headless_shell" || true + + # Bake the CI-safe flags into the binary itself (idempotent). + wrap_chromium_shim "${CHROMIUM_BIN}" +else + echo "quarto-chromium: ERROR: Chromium/Chrome Headless Shell binary not found after installation" >&2 + for search_dir in \ + "${HOME_DIR}/.local/share/quarto/chromium" \ + "${HOME_DIR}/.local/share/quarto" \ + "${HOME_DIR}/.quarto" \ + "${HOME_DIR}/.cache" \ + /root/.quarto \ + /root/.cache \ + /tmp \ + /var/cache + do + [ -d "${search_dir}" ] || continue + find "${search_dir}" -maxdepth 6 \( -type f -o -type l \) \( -name chrome -o -name chrome-headless-shell -o -name chromium -o -name chromium-browser -o -name headless_shell \) 2>/dev/null | head -n 20 || true + done + if [ -f "${HOME_DIR}/.quarto-chromium-install.log" ]; then + echo "quarto-chromium: Quarto install log tail:" >&2 + tail -n 40 "${HOME_DIR}/.quarto-chromium-install.log" >&2 || true + fi + exit 1 +fi + echo "quarto-chromium: installation complete" # Ensure jupyter-cache available for Quarto rendering tasks (if Python present) diff --git a/features/quarto-cli/feature.json b/features/quarto-cli/feature.json index ba6de83..17f7812 100644 --- a/features/quarto-cli/feature.json +++ b/features/quarto-cli/feature.json @@ -10,7 +10,7 @@ "options": { "QUARTO_VERSION": { "type": "string", - "default": "1.8.24", + "default": "1.10.18", "description": "Specific Quarto version to install" } }, diff --git a/features/quarto-cli/install.sh b/features/quarto-cli/install.sh index 1f9f1e1..d040d1e 100755 --- a/features/quarto-cli/install.sh +++ b/features/quarto-cli/install.sh @@ -149,11 +149,32 @@ fi echo "quarto-cli: found runtime at ${INSTDIR} -> ${QUARTO_EXE}" +# The tarball ships bundled tools (typst, pandoc, deno, ...) under +# /bin/tools// (arch = x86_64 | aarch64). Quarto +# itself locates them relative to its own binary (QUARTO_BIN_PATH), so +# renders work without PATH; expose the dir on PATH so that direct +# `typst` invocations (user notebook cells, scripts) resolve too. +QUARTO_TOOLS_DIR="" +for cand in "$(dirname "$QUARTO_EXE")"/tools/*/typst; do + if [ -x "$cand" ]; then + QUARTO_TOOLS_DIR="$(dirname "$cand")" + break + fi +done +if [ -n "${QUARTO_TOOLS_DIR}" ]; then + echo "quarto-cli: bundled tools dir at ${QUARTO_TOOLS_DIR}" +fi + +QUARTO_PATH_ADD="${CONDA_DIR}/bin:$(dirname "$QUARTO_EXE")" +if [ -n "${QUARTO_TOOLS_DIR}" ]; then + QUARTO_PATH_ADD="${QUARTO_PATH_ADD}:${QUARTO_TOOLS_DIR}" +fi + # Create wrapper in /usr/local/bin mkdir -p /usr/local/bin cat > /usr/local/bin/quarto < /etc/profile.d/quarto.sh </dev/null 2>&1 || true fi -echo "system-essentials: installing packages listed in /tmp/inputs/apt-packages_base (if present)" -if [ -f /tmp/inputs/apt-packages_base ]; then - PKGS=$(grep -v -e "^#" -e "^$" /tmp/inputs/apt-packages_base | tr '\n' ' ' || true) +APT_LIST="/tmp/inputs/apt-packages/base" +if [ ! -f "${APT_LIST}" ] && [ -f /tmp/inputs/apt-packages_base ]; then + APT_LIST="/tmp/inputs/apt-packages_base" +fi + +echo "system-essentials: installing packages listed in ${APT_LIST} (if present)" +if [ -f "${APT_LIST}" ]; then + PKGS=$(grep -v -e "^#" -e "^$" "${APT_LIST}" | tr '\n' ' ' || true) PKGS=$(echo "$PKGS" | xargs || true) if [ -n "$PKGS" ]; then # Use helper apt_install where available for consistent, non-interactive installs @@ -34,7 +39,7 @@ if [ -f /tmp/inputs/apt-packages_base ]; then fi fi else - echo "system-essentials: /tmp/inputs/apt-packages_base not present; skipping" + echo "system-essentials: ${APT_LIST} not present; skipping" fi echo "system-essentials: done" diff --git a/features/texlive/install.sh b/features/texlive/install.sh index 7057501..6c8e671 100755 --- a/features/texlive/install.sh +++ b/features/texlive/install.sh @@ -25,16 +25,12 @@ NB_GID=${NB_GID:-1001} HOME_DIR="/home/${NB_USER}" TEXDIR="${HOME_DIR}/.TinyTeX" INSTALLER="installer-unix" -TINYTEX_VERSION="2025.05" +TINYTEX_VERSION="2026.09" -# Pin CTAN repository to a France mirror by default to avoid auto mirror selection. -# User-provided mirrors (preferred): -# - https://ctan.ceremade.dauphine.fr (Paris) -# - https://ctan.mines-albi.fr (Albi) -# - https://ctan.tetaneutral.net (Toulouse) -# - https://distrib-coffee.ipsl.jussieu.fr (Paris) -# Default to ceremade.dauphine.fr for TLS/https access; can be overridden by setting CTAN_REPO env var. -CTAN_REPO="${CTAN_REPO:-https://ctan.ceremade.dauphine.fr/systems/texlive/tlnet}" +# Use the maintained TinyTeX TeX Live mirror by default. This keeps the TinyTeX +# installer and the downloaded TeX Live repository in sync unless the caller +# explicitly provides a compatible override. +CTAN_REPO="${CTAN_REPO:-https://tlnet.yihui.org}" # Resolve version helper (prefer per-feature artefacts/*, then central, then /tmp) resolve_version() { @@ -145,9 +141,22 @@ if [ -n "${installer_sh}" ]; then rm -rf /var/lib/apt/lists/* || true fi - pushd "$(dirname "${installer_sh}")" + export CTAN_REPO + install_root="$(cd "$(dirname "${installer_sh}")" && pwd)" + pushd "${install_root}" ./install.sh || true popd + + mkdir -p "${TEXDIR}" + if [ -d "${install_root}/texlive" ]; then + mv "${install_root}/texlive"/* "${TEXDIR}" || true + rm -rf "${install_root}/texlive" || true + elif [ -d texlive ]; then + mv texlive/* "${TEXDIR}" || true + rm -rf texlive || true + else + echo "texlive: could not locate installed texlive directory under ${install_root}" >&2 + fi rm -rf "${tmpd}" else curl -fsSL "${TINYTEX_URL}" -o /tmp/${INSTALLER}.tar.gz diff --git a/scripts/utils/update-checksums.sh b/scripts/utils/update-checksums.sh index 26882b5..5f59cad 100755 --- a/scripts/utils/update-checksums.sh +++ b/scripts/utils/update-checksums.sh @@ -3,7 +3,7 @@ set -euo pipefail # Script to download binaries for pinned versions and compute SHA256 checksums # It updates `checksums.json` (repo root) with entries like: -# { "quarto": { "1.8.24": { "amd64": "" } } } +# { "quarto": { "1.10.18": { "amd64": "" } } } REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) # Single source of truth: repo-root checksums.json and versions.json diff --git a/solen-cli/solen/core/feature.py b/solen-cli/solen/core/feature.py index c489886..fa5b739 100644 --- a/solen-cli/solen/core/feature.py +++ b/solen-cli/solen/core/feature.py @@ -185,8 +185,13 @@ def collect_feature_metadata(features_dir: Path, feature_list: list[str]) -> dic if not metadata: continue - if metadata.maintainer: - maintainers.add(metadata.maintainer) + maintainer = metadata.maintainer + if isinstance(maintainer, dict): + name = maintainer.get('name', '') + email = maintainer.get('email', '') + maintainer = f"{name} ({email})" if name and email else name or email + if maintainer: + maintainers.add(maintainer) platforms.update(metadata.platforms) provides.update(metadata.provides) @@ -198,6 +203,40 @@ def collect_feature_metadata(features_dir: Path, feature_list: list[str]) -> dic } +def collect_post_install_checks(features_dir: Path, feature_list: list[str]) -> list[dict[str, str]]: + """Collect postInstallCheck commands from features in install order.""" + checks: list[dict[str, str]] = [] + seen: set[str] = set() + + for feature_id in feature_list: + if feature_id in seen: + continue + seen.add(feature_id) + + feature_json = features_dir / feature_id / 'feature.json' + if not feature_json.exists(): + continue + + try: + data = json.loads(feature_json.read_text(encoding='utf-8')) + except Exception: + continue + + check = data.get('postInstallCheck') + if not isinstance(check, dict): + continue + + command = check.get('command') + if isinstance(command, str) and command.strip(): + checks.append({ + 'id': feature_id, + 'command': command.strip(), + 'description': str(check.get('description', '')), + }) + + return checks + + def collect_feature_options(features_dir: Path, feature_list: list[str]) -> dict[str, str]: """Collect default option values from all features. diff --git a/solen-cli/solen/generators/dockerfile_gen.py b/solen-cli/solen/generators/dockerfile_gen.py index 43f7de3..1b87e92 100644 --- a/solen-cli/solen/generators/dockerfile_gen.py +++ b/solen-cli/solen/generators/dockerfile_gen.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 from pathlib import Path from typing import Any @@ -8,6 +9,7 @@ from solen.core.feature import ( collect_feature_metadata, collect_feature_options, + collect_post_install_checks, expand_feature_dependencies, topological_sort_features, ) @@ -45,6 +47,7 @@ def build_profile_data(repo_root: Path, profile_name: str) -> dict[str, Any]: metadata = collect_feature_metadata(features_dir, features) feature_options = collect_feature_options(features_dir, features) + checks = collect_post_install_checks(features_dir, features) all_options = {**feature_options, **profile_data.options} return { @@ -53,6 +56,13 @@ def build_profile_data(repo_root: Path, profile_name: str) -> dict[str, Any]: "features_str": " ".join(features) if features else "none", "provides_str": ",".join(metadata.get("provides", [])), "env": {k: docker_escape(v) for k, v in all_options.items()}, + "checks": [ + { + **check, + "command_b64": base64.b64encode(check["command"].encode("utf-8")).decode("ascii"), + } + for check in checks + ], } diff --git a/solen-cli/solen/generators/dockerfile_template.j2 b/solen-cli/solen/generators/dockerfile_template.j2 index 1007dc9..e91fc01 100644 --- a/solen-cli/solen/generators/dockerfile_template.j2 +++ b/solen-cli/solen/generators/dockerfile_template.j2 @@ -20,6 +20,62 @@ RUN mkdir -p /opt/.features /scripts/lib && \ printf "source /opt/solen/_lib/helpers.sh || true" > /scripts/lib/features.sh RUN if [ -f /opt/solen/_lib/helpers.sh ]; then echo "\n# Source fh_helpers (fh_*) if present" >> /opt/solen/_lib/helpers.sh && echo "source /opt/solen/_lib/fh_helpers.sh || true" >> /opt/solen/_lib/helpers.sh; fi +RUN cat > /opt/solen/run-check-env.sh <<'SCRIPT' +#!/usr/bin/env bash +set -uo pipefail + +user="${1:-${NB_USER:-jovyan}}" +check_cmd="${2:-}" +if [ -z "${check_cmd}" ]; then + echo "run-check-env: missing check command" >&2 + exit 2 +fi + +user_home="/home/${user}" +extra_path="${user_home}/.local/bin:${user_home}/bin:${user_home}/.quarto/bin:${user_home}/miniforge3/bin:${user_home}/.linuxbrew/bin:${user_home}/.linuxbrew/opt/bin:/opt/quarto/bin:/usr/local/bin" + +for d in "${user_home}/.TinyTeX"/bin/*/; do + if [ -d "${d}" ]; then + extra_path="${d%/}:${extra_path}" + break + fi +done + +export PATH="${extra_path}:${PATH}" + +if [ -f "${user_home}/.sdkman/bin/sdkman-init.sh" ]; then + set +u + # shellcheck source=/dev/null + . "${user_home}/.sdkman/bin/sdkman-init.sh" >/dev/null 2>&1 || true + set -u +fi + +eval "${check_cmd}" +SCRIPT +RUN chmod +x /opt/solen/run-check-env.sh + +RUN cat > /opt/solen/_lib/run-check-as-user.sh <<'SCRIPT' +run_check_as_user() { + local check_cmd="$1" + local user="${NB_USER:-jovyan}" + local wrapper="/opt/solen/run-check-env.sh" + + if [ "$(id -un)" = "$user" ]; then + "$wrapper" "$user" "$check_cmd" + elif command -v runuser >/dev/null 2>&1; then + runuser -u "$user" -- "$wrapper" "$user" "$check_cmd" + elif command -v setpriv >/dev/null 2>&1; then + local uid + local gid + uid="$(id -u "$user")" + gid="$(id -g "$user")" + setpriv --reuid="$uid" --regid="$gid" --init-groups "$wrapper" "$user" "$check_cmd" + else + su -s /bin/bash "$user" -c "\"$wrapper\" \"$user\" \"$check_cmd\"" + fi +} +SCRIPT + {% if toolcache_exists %} # Inject prebaked toolcache from repository COPY generated/toolcache /opt/toolcache @@ -38,6 +94,46 @@ LABEL org.solen.profile="{{ profile.name }}" \ ENV {{ k }}="{{ v }}" {% endfor %} +ARG RUN_POSTINSTALL_CHECKS="false" +ENV RUN_POSTINSTALL_CHECKS="${RUN_POSTINSTALL_CHECKS}" + +RUN cat > /opt/solen/run-postinstall-checks.sh <<'SCRIPT' +#!/usr/bin/env bash +set -uo pipefail + +source /opt/solen/_lib/run-check-as-user.sh + +features=( +{% for check in profile.checks %} + "{{ check.id }}" +{% endfor %} +) +commands_b64=( +{% for check in profile.checks %} + "{{ check.command_b64 }}" +{% endfor %} +) + +total=0 +failed=0 +echo "Starting Solen post-install checks" +for i in "${!features[@]}"; do + feature="${features[$i]}" + check_cmd="$(printf '%s' "${commands_b64[$i]}" | base64 -d)" + total=$((total + 1)) + echo "=== RUN postInstallCheck: $feature" + if run_check_as_user "$check_cmd"; then + echo "OK postInstallCheck: $feature" + else + echo "FAIL postInstallCheck: $feature" >&2 + failed=$((failed + 1)) + fi +done +echo "Solen post-install checks complete: $((total - failed))/$total passed" +exit "$failed" +SCRIPT +RUN chmod +x /opt/solen/run-postinstall-checks.sh + RUN \ {% for feat in profile.features %} --mount=type=bind,source=features/{{ feat }},target=/tmp/features/{{ feat }},readonly \ @@ -60,12 +156,24 @@ echo "=== debug: helpers.sh head"; head -n 40 /opt/solen/_lib/helpers.sh || true echo "=== debug: fh_helpers.sh head"; head -n 40 /opt/solen/_lib/fh_helpers.sh || true echo "=== debug: fh_resolve_checksum exists?"; type -t fh_resolve_checksum || echo "fh_resolve_checksum: not-found" +if [ "${RUN_POSTINSTALL_CHECKS:-false}" = "true" ]; then + declare -A feature_check=() +{% for check in profile.checks %} + feature_check["{{ check.id }}"]="{{ check.command_b64 }}" +{% endfor %} + source /opt/solen/_lib/run-check-as-user.sh +fi + for f in "${feats[@]}"; do if [ -f "/tmp/features/$f/install.sh" ]; then chmod +x /tmp/features/$f/install.sh || true set +u bash /tmp/features/$f/install.sh set -u + if [ "${RUN_POSTINSTALL_CHECKS:-false}" = "true" ] && [ -n "${feature_check[$f]:-}" ]; then + check_cmd="$(printf '%s' "${feature_check[$f]}" | base64 -d)" + run_check_as_user "$check_cmd" + fi fi done @@ -83,6 +191,10 @@ for f in "${feats[@]}"; do fi done +if [ "${RUN_POSTINSTALL_CHECKS:-false}" = "true" ]; then + /opt/solen/run-postinstall-checks.sh +fi + apt-get clean rm -rf /var/lib/apt/lists/auxfiles /var/lib/apt/lists/lock /var/lib/apt/lists/partial SCRIPT diff --git a/tests/test_postinstall_check_collection.py b/tests/test_postinstall_check_collection.py new file mode 100644 index 0000000..ece5222 --- /dev/null +++ b/tests/test_postinstall_check_collection.py @@ -0,0 +1,20 @@ +from pathlib import Path + +from solen.core.feature import collect_post_install_checks + + +def test_collect_post_install_checks_returns_java_kernel_command(): + repo_root = Path(__file__).resolve().parents[1] + checks = collect_post_install_checks(repo_root / 'features', ['java-kernel']) + + assert checks + assert checks[0]['id'] == 'java-kernel' + assert 'from jupyter_client.kernelspec import KernelSpecManager' in checks[0]['command'] + assert 'java kernel jar missing' in checks[0]['command'] + + +def test_collect_post_install_checks_skips_missing_feature(): + repo_root = Path(__file__).resolve().parents[1] + checks = collect_post_install_checks(repo_root / 'features', ['does-not-exist']) + + assert checks == [] diff --git a/versions.json b/versions.json index 750374c..48e5e31 100644 --- a/versions.json +++ b/versions.json @@ -6,7 +6,7 @@ "buildx": "v0.30.1", "compose": "v5.0.1", "docker": "27-cli", - "quarto": "v1.8.26", + "quarto": "v1.10.18", "node": "22.12.0", "docker-ce": "27.5.1", "docker-compose": "v5.0.1", @@ -20,8 +20,8 @@ "minikube": "1.37.0", "gh": "2.83.2", "scout": "v1.19.0", - "tinytex": "2025.09", - "java-kernel": "1.4.5", + "tinytex": "2026.09", + "java-kernel": "1.4.6-pr12", "tilt": "v0.36.0", "miniforge": "25.11.0-1", "mongodb": "8.0", diff --git a/versions/versions.yaml b/versions/versions.yaml index a20b5c4..e415350 100644 --- a/versions/versions.yaml +++ b/versions/versions.yaml @@ -21,7 +21,7 @@ tools: - type: github repo: "docker/cli" quarto: - version: "v1.8.26" + version: "v1.10.18" sources: - type: github repo: "quarto-dev/quarto-cli" @@ -92,12 +92,12 @@ tools: - type: github repo: "docker/scout-cli" tinytex: - version: "2025.09" + version: "2026.09" sources: - type: github repo: "yihui/tinytex" java-kernel: - version: "1.4.5" + version: "1.4.6-pr12" sources: - type: github repo: "ebpro/IJava"