From fd18b547e5cd4e506e2bb91fd37bc670b259598f Mon Sep 17 00:00:00 2001 From: Catty Steve <4795515+Catty2014@user.noreply.gitee.com> Date: Tue, 28 Apr 2026 12:20:05 +0800 Subject: [PATCH] feat(upm): implement Rust user package manager Adds the `user.rust` UPM to workshop-engine for managing Rust toolchains, components, cross-compilation targets, and cargo operations: - `rustup default ` / `rustup component add` / `rustup target add` - `cargo fetch` for dependency pre-fetch with optional `--locked` - `cargo install` for global crate tools with registry support Includes 275-line test suite (`tests/upm/test_rust.py`) covering the full lifecycle with optional network tests. --- .gitignore | 2 + runner.sh | 226 ++++++++++++---- tests/baker/test_bake.py | 36 +-- tests/baker/test_finalize.py | 21 +- tests/baker/test_prebake.py | 65 +---- tests/conftest.py | 66 +++-- tests/fixtures/prebake/with_pacman.yml | 1 + tests/llm/cache.py | 4 +- tests/upm/__init__.py | 0 tests/upm/test_rust.py | 275 ++++++++++++++++++++ workshop-baker/src/prebake/stage/hook.rs | 4 +- workshop-engine/src/types.rs | 3 +- workshop-engine/src/upm.rs | 3 +- workshop-engine/src/upm/rust.rs | 316 +++++++++++++++++++++++ 14 files changed, 825 insertions(+), 197 deletions(-) create mode 100644 tests/upm/__init__.py create mode 100644 tests/upm/test_rust.py create mode 100644 workshop-engine/src/upm/rust.rs diff --git a/.gitignore b/.gitignore index 15eac4f..3e0ea9c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ tests/**/__pycache__/ tests/.pytest_cache/ tests/llm/.cache/ *.pyc +.staging +.pytest_cache diff --git a/runner.sh b/runner.sh index c324eec..438fd83 100755 --- a/runner.sh +++ b/runner.sh @@ -4,97 +4,213 @@ set -euo pipefail # HoneyBiscuitWorkshop System Test Runner # # Usage: -# ./runner.sh # Arch baker tests (hbw-system-test:latest) -# ./runner.sh --image hbw-test-pm:test-apt # Debian engine PM tests -# ./runner.sh -k test_install # Arch baker, filtered - -MODE="auto" -TEST_IMAGE="" -TEST_DIR="tests/baker" -PYTEST_ARGS="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --image) TEST_IMAGE="$2"; TEST_DIR="tests/engine"; shift 2 ;; - manual) MODE="manual"; shift ;; - auto) MODE="auto"; shift ;; - --) shift; PYTEST_ARGS="$@"; break ;; - *) PYTEST_ARGS="$PYTEST_ARGS $1"; shift ;; - esac -done - -# Default image: Arch-based baker test image -: ${TEST_IMAGE:="hbw-system-test:latest"} +# ./runner.sh # baker tests (default) +# ./runner.sh -c upm:rust # Rust UPM tests +# ./runner.sh -c pm:pacman # pacman PM engine tests +# ./runner.sh -c pm:apt -k test_mirror # apt mirror test only +# ./runner.sh -c upm:rust --network # include network tests SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONTAINER_NAME="hbw-system-test-container" -STAGING_DIR="/tmp/hbw-system-test" +STAGING_DIR="$SCRIPT_DIR/.staging" + +# ── defaults ──────────────────────────────────────────────────────── + +MODE="auto" +CATEGORY="baker" # baker | upm: | pm: +TEST_DIR="" +TEST_IMAGE="" +PYTEST_ARGS=() +NETWORK=0 +BAKER_LOG=1 + +# ── parse args ────────────────────────────────────────────────────── + +show_help() { + cat << 'HELP' +Usage: ./runner.sh [OPTIONS] + +Categories (-c): + baker Baker system tests [default] + upm:rust Rust UPM tests + pm:pacman Pacman PM engine tests + pm:apt APT PM engine tests + pm:dnf DNF PM engine tests + pm:apk APK PM engine tests + +Options: + -c, --category CAT Test category (see above) + --network Allow network-dependent tests (toolchain/crate downloads) + -k FILTER pytest -k expression + --timeout SECONDS Per-test timeout (default 120) + --log / --no-log Stream baker output to terminal [default: --log] + -h, --help This message +HELP + exit 0 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -c|--category) + CATEGORY="$2"; shift 2 ;; + --network) + NETWORK=1; shift ;; + -k) + PYTEST_ARGS+=(-k "$2"); shift 2 ;; + --timeout) + PYTEST_ARGS+=(--timeout "$2"); shift 2 ;; + --log) BAKER_LOG=1; shift ;; + --no-log) BAKER_LOG=0; shift ;; + manual) MODE="manual"; shift ;; + auto) MODE="auto"; shift ;; + -h|--help) + show_help ;; + --) + shift; PYTEST_ARGS+=("$@"); break ;; + *) + PYTEST_ARGS+=("$1"); shift ;; + esac +done + +# ── resolve category → test dir + image ───────────────────────────── + +case "$CATEGORY" in + baker) + TEST_DIR="tests/baker" + TEST_IMAGE="hbw-system-test:latest" + ;; + upm:rust) + TEST_DIR="tests/upm" + TEST_IMAGE="hbw-system-test:latest" + ;; + pm:pacman|pm:arch) + TEST_DIR="tests/engine" + TEST_IMAGE="hbw-test-pm:test-arch" + ;; + pm:apt) + TEST_DIR="tests/engine" + TEST_IMAGE="hbw-test-pm:test-apt" + ;; + pm:dnf) + TEST_DIR="tests/engine" + TEST_IMAGE="hbw-test-pm:test-dnf" + ;; + pm:apk) + TEST_DIR="tests/engine" + TEST_IMAGE="hbw-test-pm:test-apk" + ;; + *) + echo "Unknown category: $CATEGORY" >&2 + echo "Run './runner.sh --help' for usage." >&2 + exit 1 + ;; +esac + +# ── env vars ────────────────────────────────────────────────────── + +DOCKER_ENV=() +DOCKER_ENV+=(-e RUST_LOG=${RUST_LOG:-info}) +DOCKER_ENV+=(-e BAKER_LOG=${BAKER_LOG:-1}) + +# Load llm.env if present (for LLM-based test evaluation) +if [ -f "$SCRIPT_DIR/llm.env" ]; then + while IFS='=' read -r key value; do + [ -z "$key" ] && continue + [[ "$key" =~ ^# ]] && continue + DOCKER_ENV+=(-e "$key=$value") + done < "$SCRIPT_DIR/llm.env" +fi + +if [ "$NETWORK" -eq 1 ]; then + DOCKER_ENV+=(-e RUST_UPM_NETWORK=1) +fi + +# ── cleanup ───────────────────────────────────────────────────────── cleanup() { docker rm -f "$CONTAINER_NAME" 2>/dev/null || true - [ "$TEST_DIR" = "tests/baker" ] && rm -rf "$STAGING_DIR" } trap cleanup EXIT +# ── build baker binary ────────────────────────────────────────────── + build_binary() { local target="x86_64-unknown-linux-musl" local binary="$SCRIPT_DIR/workshop-baker/target/$target/debug/workshop-baker" if [ ! -f "$binary" ]; then echo "[runner] Building static (musl) baker binary..." - cd "$SCRIPT_DIR/workshop-baker" cargo build -p workshop-baker --target "$target" 2>&1 | tail -n 1 fi } +# ── staging ───────────────────────────────────────────────────────── + prepare_staging() { - # Only needed for baker tests (hbw-system-test:latest mounts /workshop) - if [ "$TEST_DIR" = "tests/baker" ]; then + if [ "$TEST_DIR" != "tests/engine" ]; then mkdir -p "$STAGING_DIR" cp "$SCRIPT_DIR/workshop-baker/target/x86_64-unknown-linux-musl/debug/workshop-baker" "$STAGING_DIR/" chmod +x "$STAGING_DIR/workshop-baker" fi } -run_container() { - local tag="${TEST_IMAGE##*:}" - local filter="" - case "$tag" in - test-arch) filter="-k pacman" ;; - test-apt) filter="-k apt" ;; - test-dnf) filter="-k dnf" ;; - test-apk) filter="-k apk" ;; +# ── pm filter ─────────────────────────────────────────────────────── + +pm_filter() { + case "${TEST_IMAGE##*:}" in + test-arch) echo "-k pacman" ;; + test-apt) echo "-k apt" ;; + test-dnf) echo "-k dnf" ;; + test-apk) echo "-k apk" ;; + *) echo "" ;; esac +} - echo "[runner] Starting: $TEST_IMAGE (tests=$TEST_DIR${filter:+, filter=$filter})" - mkdir -p "$SCRIPT_DIR/tests/results" +# ── run ───────────────────────────────────────────────────────────── - local mounts="-v $SCRIPT_DIR:/src:ro" - local entrypoint="" - - if [ "$TEST_DIR" = "tests/baker" ]; then - mounts="$mounts -v $STAGING_DIR:/workshop:ro" - entrypoint='--entrypoint ""' +run_container() { + local filter="" + if [ "$TEST_DIR" = "tests/engine" ]; then + filter="$(pm_filter)" fi - local cmd="sh -c \"python3 -m venv /tmp/v && . /tmp/v/bin/activate && \ - pip install pytest pytest-timeout -q && \ - exec python -m pytest $TEST_DIR $PYTEST_ARGS $filter\"" + echo "============================================" + echo " HBW System Test Runner" + echo " Category: $TEST_DIR Image: $TEST_IMAGE" + echo "============================================" + + mkdir -p "$SCRIPT_DIR/tests/results" + + local mounts=(-v "$SCRIPT_DIR:/src:ro") + if [ "$TEST_DIR" != "tests/engine" ]; then + mounts+=(-v "$STAGING_DIR:/workshop") # writable: baker writes temp scripts here + fi + + local pytest_cmd="python3 -m venv /tmp/v && . /tmp/v/bin/activate && \ + pip install -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple \ + -r $TEST_DIR/../requirements.txt -q && \ + exec python -m pytest -v -s $TEST_DIR ${PYTEST_ARGS[*]} $filter" if [ "$MODE" = "manual" ]; then - docker run -it --rm --name "$CONTAINER_NAME" --privileged $entrypoint \ - $mounts "$TEST_IMAGE" + docker run -it --rm --name "$CONTAINER_NAME" --privileged \ + ${DOCKER_ENV[@]} "${mounts[@]}" "$TEST_IMAGE" else - docker run --rm --name "$CONTAINER_NAME" --privileged $entrypoint \ - $mounts -w /src \ - "$TEST_IMAGE" "$cmd" + # Baker/UPM images (hbw-system-test) have ./entrypoint.sh which + # doesn't exist when /src is the workdir → override to empty. + # PM images (hbw-test-pm:*) have /bin/sh -c → CMD passed directly. + if [ "$TEST_DIR" = "tests/engine" ]; then + docker run --rm --name "$CONTAINER_NAME" --privileged \ + ${DOCKER_ENV[@]} "${mounts[@]}" -w /src \ + "$TEST_IMAGE" "$pytest_cmd" + else + docker run --rm --name "$CONTAINER_NAME" --privileged \ + --entrypoint "" ${DOCKER_ENV[@]} "${mounts[@]}" -w /src \ + "$TEST_IMAGE" /bin/sh -c "$pytest_cmd" + fi fi } -echo "============================================" -echo " HBW System Test Runner" -echo " Image: $TEST_IMAGE" -echo " Tests: $TEST_DIR" -echo "============================================" +# ── main ──────────────────────────────────────────────────────────── + build_binary prepare_staging run_container diff --git a/tests/baker/test_bake.py b/tests/baker/test_bake.py index ce95e9a..6afb43c 100644 --- a/tests/baker/test_bake.py +++ b/tests/baker/test_bake.py @@ -1,16 +1,14 @@ import os import pytest - -FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures", "bake") - +FIXTURES = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures", "bake") @pytest.mark.bake class TestBakeSimpleScript: def test_simple_script_runs(self, run_baker, bake_workspace, work_dir): prebake_cfg = os.path.join( - os.path.dirname(__file__), "fixtures", "prebake", "minimal.yml" + os.path.dirname(os.path.dirname(__file__)), "fixtures", "prebake", "minimal.yml" ) script = os.path.join(FIXTURES, "simple.sh") bake_base = os.path.join(FIXTURES, "bake_base.sh") @@ -27,7 +25,7 @@ class TestBakeSimpleScript: def test_simple_script_sets_env_vars(self, run_baker, bake_workspace, work_dir): prebake_cfg = os.path.join( - os.path.dirname(__file__), "fixtures", "prebake", "minimal.yml" + os.path.dirname(os.path.dirname(__file__)), "fixtures", "prebake", "minimal.yml" ) script = os.path.join(FIXTURES, "simple.sh") bake_base = os.path.join(FIXTURES, "bake_base.sh") @@ -42,13 +40,12 @@ class TestBakeSimpleScript: assert result["success"] assert "test-pipeline" in result["stdout"] - @pytest.mark.bake class TestBakePipeline: def test_pipeline_functions_execute(self, run_baker, bake_workspace, work_dir): prebake_cfg = os.path.join( - os.path.dirname(__file__), "fixtures", "prebake", "minimal.yml" + os.path.dirname(os.path.dirname(__file__)), "fixtures", "prebake", "minimal.yml" ) script = os.path.join(FIXTURES, "pipeline.sh") bake_base = os.path.join(FIXTURES, "bake_base.sh") @@ -66,7 +63,7 @@ class TestBakePipeline: def test_pipeline_step_outputs_correct(self, run_baker, bake_workspace, work_dir): prebake_cfg = os.path.join( - os.path.dirname(__file__), "fixtures", "prebake", "minimal.yml" + os.path.dirname(os.path.dirname(__file__)), "fixtures", "prebake", "minimal.yml" ) script = os.path.join(FIXTURES, "pipeline.sh") bake_base = os.path.join(FIXTURES, "bake_base.sh") @@ -84,13 +81,12 @@ class TestBakePipeline: with open("/tmp/bake-test-output/step2.txt") as f: assert f.read().strip() == "step2-done" - @pytest.mark.bake class TestBakeAfterDependencies: def test_after_ordering(self, run_baker, bake_workspace, work_dir): prebake_cfg = os.path.join( - os.path.dirname(__file__), "fixtures", "prebake", "minimal.yml" + os.path.dirname(os.path.dirname(__file__)), "fixtures", "prebake", "minimal.yml" ) script = os.path.join(FIXTURES, "with_after.sh") bake_base = os.path.join(FIXTURES, "bake_base.sh") @@ -107,23 +103,3 @@ class TestBakeAfterDependencies: assert os.path.exists("/tmp/bake-after-test/source.txt") assert os.path.exists("/tmp/bake-after-test/build.txt") - -@pytest.mark.bake -class TestBakeStatusFiles: - - def test_status_written_after_bake(self, run_baker, bake_workspace, work_dir): - prebake_cfg = os.path.join( - os.path.dirname(__file__), "fixtures", "prebake", "minimal.yml" - ) - script = os.path.join(FIXTURES, "simple.sh") - bake_base = os.path.join(FIXTURES, "bake_base.sh") - - result = run_baker( - "bake", script, - "--bake-base", bake_base, - "--prebake", prebake_cfg, - cwd=work_dir, - timeout=60, - ) - assert result["success"] - assert os.path.exists("/tmp/.hbwstatus") diff --git a/tests/baker/test_finalize.py b/tests/baker/test_finalize.py index 572b8a5..8626af4 100644 --- a/tests/baker/test_finalize.py +++ b/tests/baker/test_finalize.py @@ -4,9 +4,7 @@ import pytest from probes import file from llm import judge_log, LLMClient - -FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures", "finalize") - +FIXTURES = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures", "finalize") @pytest.mark.finalize class TestFinalizeMinimal: @@ -16,7 +14,6 @@ class TestFinalizeMinimal: result = run_baker("finalize", config, cwd=work_dir, timeout=60) assert result["success"], f"Minimal finalize failed: {result['stderr']}" - @pytest.mark.finalize class TestFinalizeHooks: @@ -32,17 +29,6 @@ class TestFinalizeHooks: assert result["success"] assert "finalize late hook" in result["stdout"] or "finalize late hook" in result["stderr"] - -@pytest.mark.finalize -class TestFinalizeStatus: - - def test_finalize_writes_status(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "minimal.yml") - result = run_baker("finalize", config, cwd=work_dir, timeout=60) - assert result["success"] - assert os.path.exists("/tmp/.hbwstatus") - - @pytest.mark.finalize @pytest.mark.llm class TestFinalizeWithLLM: @@ -64,14 +50,13 @@ class TestFinalizeWithLLM: ) assert judgment.passed, f"LLM judgment failed: {judgment.reason}" - @pytest.mark.finalize class TestFinalizePlugins: - @pytest.mark.xfail(reason="Plugin download requires network/git access", strict=False) + @pytest.mark.skip(reason="Plugin download test not yet implemented") def test_plugin_download(self, run_baker, work_dir): pass - @pytest.mark.xfail(reason="Artifact stage not yet implemented", strict=False) + @pytest.mark.skip(reason="Artifact stage not yet implemented") def test_artifact_collection(self, run_baker, work_dir): pass diff --git a/tests/baker/test_prebake.py b/tests/baker/test_prebake.py index 10914bc..a513ace 100644 --- a/tests/baker/test_prebake.py +++ b/tests/baker/test_prebake.py @@ -4,9 +4,7 @@ import pytest from probes import user, file, package - -FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures", "prebake") - +FIXTURES = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures", "prebake") @pytest.mark.prebake class TestPrebakeConfigValidation: @@ -29,7 +27,6 @@ class TestPrebakeConfigValidation: result = run_baker("prebake", config, cwd=work_dir, timeout=60) assert result["success"], f"Minimal prebake failed: {result['stderr']}" - @pytest.mark.prebake class TestPrebakeBootstrap: @@ -89,7 +86,6 @@ class TestPrebakeBootstrap: assert result["success"], f"Custom bootstrap failed: {result['stderr']}" assert file.dir_exists("/custom-workspace") - @pytest.mark.prebake class TestPrebakeDryRun: @@ -107,7 +103,6 @@ class TestPrebakeDryRun: combined = result["stdout"] + result["stderr"] assert "DRY_RUN" in combined or "dry_run" in combined.lower() - @pytest.mark.prebake class TestPrebakeHooks: @@ -143,17 +138,15 @@ class TestPrebakeHooks: result = run_baker("prebake", config, cwd=work_dir, timeout=60) assert result["success"], f"prebake with no hooks failed: {result['stderr']}" - @pytest.mark.prebake class TestPrebakeDepsSystem: - @pytest.mark.xfail(reason="Package installation may fail in test environment", strict=False) def test_pacman_packages_installed(self, run_baker, work_dir): config = os.path.join(FIXTURES, "with_pacman.yml") result = run_baker("prebake", config, cwd=work_dir, timeout=120) assert result["success"], f"prebake failed: {result['stderr']}" - assert package.package_installed("curl") - assert package.package_installed("wget") + assert package.binary_exists("/usr/bin/curl") + assert package.binary_exists("/usr/bin/wget") def test_pacman_mirror_change(self, run_baker, work_dir): mirror_config = os.path.join(work_dir, "mirror.yml") @@ -173,7 +166,6 @@ dependencies: result = run_baker("prebake", mirror_config, cwd=work_dir, timeout=120) assert result["success"], f"prebake with mirror failed: {result['stderr']}" - @pytest.mark.prebake class TestPrebakeEnvVars: @@ -199,7 +191,6 @@ hooks: combined = result["stdout"] + result["stderr"] assert "injected_value" in combined - @pytest.mark.prebake class TestPrebakeSecurity: @@ -209,53 +200,3 @@ class TestPrebakeSecurity: result = run_baker("prebake", config, cwd=work_dir, timeout=60) assert result["success"], f"prebake with security failed: {result['stderr']}" - -@pytest.mark.prebake -class TestPrebakeStatus: - - def test_status_files_written(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "bootstrap_only.yml") - result = run_baker("prebake", config, cwd=work_dir, timeout=60) - assert result["success"] - assert os.path.exists("/tmp/.hbwstatus") - - def test_status_contains_bootstrap_stage(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "bootstrap_only.yml") - result = run_baker("prebake", config, cwd=work_dir, timeout=60) - assert result["success"] - with open("/tmp/.hbwstatus", "r") as f: - lines = f.readlines() - assert len(lines) > 0 - bootstrap_lines = [l for l in lines if json.loads(l).get("name") == "bootstrap"] - assert len(bootstrap_lines) > 0, "No bootstrap stage found in status file" - data = json.loads(bootstrap_lines[-1]) - assert data.get("phase") == "Prebake" - assert data.get("result") == "Success" - - def test_status_contains_duration(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "bootstrap_only.yml") - result = run_baker("prebake", config, cwd=work_dir, timeout=60) - assert result["success"] - with open("/tmp/.hbwstatus", "r") as f: - data = json.loads(f.readline()) - assert "duration_ms" in data - assert isinstance(data["duration_ms"], int) - - def test_status_contains_timestamps(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "bootstrap_only.yml") - result = run_baker("prebake", config, cwd=work_dir, timeout=60) - assert result["success"] - with open("/tmp/.hbwstatus", "r") as f: - data = json.loads(f.readline()) - assert "started_at" in data - assert "finished_at" in data - - def test_status_written_for_each_stage(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "with_pacman.yml") - result = run_baker("prebake", config, cwd=work_dir, timeout=120) - assert result["success"], f"prebake failed: {result['stderr']}" - with open("/tmp/.hbwstatus", "r") as f: - lines = f.readlines() - names = [json.loads(line).get("name") for line in lines] - assert "bootstrap" in names - assert "depssystem" in names diff --git a/tests/conftest.py b/tests/conftest.py index c7dc172..4571301 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -77,36 +77,52 @@ def run_cmd(): """ def _run(args, cwd=None, env=None, timeout=120): - """Run a command and capture output. - - Args: - args: Command as list of strings. - cwd: Working directory. - env: Environment variables (merged with os.environ). - timeout: Timeout in seconds. - - Returns: - dict with keys: stdout, stderr, returncode, success - """ full_env = {**os.environ} if env: full_env.update(env) + show_logs = os.environ.get("BAKER_LOG", "0") == "1" + try: - result = subprocess.run( - args, - cwd=cwd, - env=full_env, - capture_output=True, - text=True, - timeout=timeout, - ) - return { - "stdout": result.stdout, - "stderr": result.stderr, - "returncode": result.returncode, - "success": result.returncode == 0, - } + if show_logs: + # Stream output to terminal in real-time while capturing + import sys, select + p = subprocess.Popen( + args, cwd=cwd, env=full_env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, + ) + stdout_chunks, stderr_chunks = [], [] + try: + p.wait(timeout=timeout) + except subprocess.TimeoutExpired: + p.kill() + p.wait() + return {"stdout": "", "stderr": f"Command timed out after {timeout}s", + "returncode": -1, "success": False} + for line in p.stdout: + sys.stderr.write("[baker] " + line) + stdout_chunks.append(line) + for line in p.stderr: + sys.stderr.write("[baker:err] " + line) + stderr_chunks.append(line) + return { + "stdout": "".join(stdout_chunks), + "stderr": "".join(stderr_chunks), + "returncode": p.returncode, + "success": p.returncode == 0, + } + else: + result = subprocess.run( + args, cwd=cwd, env=full_env, + capture_output=True, text=True, timeout=timeout, + ) + return { + "stdout": result.stdout, + "stderr": result.stderr, + "returncode": result.returncode, + "success": result.returncode == 0, + } except subprocess.TimeoutExpired: return { "stdout": "", diff --git a/tests/fixtures/prebake/with_pacman.yml b/tests/fixtures/prebake/with_pacman.yml index 36da80b..04f5116 100644 --- a/tests/fixtures/prebake/with_pacman.yml +++ b/tests/fixtures/prebake/with_pacman.yml @@ -9,6 +9,7 @@ bootstrap: dependencies: system: pacman: + mirror: "https://mirrors.tuna.tsinghua.edu.cn/archlinux/$repo/os/$arch" packages: - "curl" - "wget" diff --git a/tests/llm/cache.py b/tests/llm/cache.py index baf12a5..a0767b7 100644 --- a/tests/llm/cache.py +++ b/tests/llm/cache.py @@ -15,9 +15,7 @@ class LLMCache: def __init__(self, cache_dir: str | None = None): if cache_dir is None: - import os - - cache_dir = os.path.join(os.path.dirname(__file__), ".cache") + cache_dir = "/tmp/llm-cache" self.cache_dir = Path(cache_dir) self.cache_dir.mkdir(parents=True, exist_ok=True) diff --git a/tests/upm/__init__.py b/tests/upm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/upm/test_rust.py b/tests/upm/test_rust.py new file mode 100644 index 0000000..d59b1f1 --- /dev/null +++ b/tests/upm/test_rust.py @@ -0,0 +1,275 @@ +"""Rust UPM system tests driven by bare workshop-baker prebake. + +These tests exercise the ``user.rust`` UPM through its full lifecycle: + 1. sysdep injection (rustup via pacman, repology_endpoint: none) + 2. rustup toolchain / component / target installation + 3. cargo fetch (project dependency pre-fetch) + 4. cargo install (global crate tools) + +Observability + * ``RUST_LOG=info`` is injected into every baker invocation. + * On failure the *full* stdout + stderr is printed (no truncation). + * An LLM judge (``tests/llm/judge.py``) diagnoses failures with a + human-readable reason, cached per-log-hash to save tokens. + +Network-dependent tests (toolchain download) are skipped by default. +Set ``RUST_UPM_NETWORK=1`` to run them. +""" + +import os +import subprocess +import pytest +from probes import binary_exists + +# ── helpers ───────────────────────────────────────────────────────── + +RUST_LOG = os.environ.get("RUST_LOG", "info") + + +def _yml(user_section): + """Build a minimal prebake.yml focused on the rust UPM.""" + return """\ +version: "1.0" +environment: + builder: "baremetal" +bootstrap: + user: "vulcan" +security: + drop_after: never +envvars: + prebake: + RUSTUP_DIST_SERVER: "https://mirrors.tuna.tsinghua.edu.cn/rustup" + +dependencies: + config: + repology_endpoint: none + system: + pacman: + packages: + user: + rust: +""" + _indent(user_section, 6) + + +def _indent(text, spaces): + pad = " " * spaces + return "\n".join(pad + line if line.strip() else line + for line in text.splitlines()) + + +def _cargo_toml(dir_path): + path = os.path.join(dir_path, "Cargo.toml") + with open(path, "w") as f: + f.write('[package]\nname = "test"\nversion = "0.1.0"\nedition = "2021"\n') + return path + + +def _query(cmd, timeout=60): + try: + r = subprocess.run(cmd, capture_output=True, text=True, + timeout=timeout, env={**os.environ, "HOME": "/root"}) + return {"success": r.returncode == 0, "stdout": r.stdout, + "stderr": r.stderr} + except Exception as e: + return {"success": False, "stdout": "", "stderr": str(e)} + + +def _run_prebake(run_baker, config_path, timeout, operation, expected): + """Run prebake with RUST_LOG, return (result, full_log).""" + env_vars = {"RUST_LOG": RUST_LOG} + r = run_baker("prebake", config_path, timeout=timeout, env=env_vars) + full_log = "[stdout]\n{}\n[stderr]\n{}".format(r["stdout"], r["stderr"]) + return r, full_log + + +def _check(r, full_log, operation, expected): + """Assert success; on failure call LLM judge and show full output.""" + if r["success"]: + return + + diagnosis = "" + try: + from llm import judge_log + j = judge_log(operation, expected, full_log) + diagnosis = "\n LLM diagnosis: [{}] {}".format(j.verdict, j.reason) + except Exception: + pass + + pytest.fail( + "{} failed ({} chars):\n{}\n{}".format( + operation, len(full_log), full_log, diagnosis) + ) + + +# ── system dependency resolution ──────────────────────────────────── + + +def test_repology_none_config(run_baker, work_dir): + cfg = os.path.join(work_dir, "rust.yml") + with open(cfg, "w") as f: + f.write(_yml("""\ +cargo: {} +""")) + + r, log = _run_prebake(run_baker, cfg, timeout=120, + operation="UPM sysdep: repology_endpoint=none", + expected="rustup gets installed via pacman") + + _check(r, log, "repology-none sysdep resolution", + "pacman installs rustup, empty cargo config") + + assert binary_exists("/usr/bin/rustup"), "rustup not installed via sysdep" + + +# ── rustup: toolchain & components ────────────────────────────────── + + +@pytest.mark.skipif( + "RUST_UPM_NETWORK" not in os.environ, + reason="set RUST_UPM_NETWORK=1 for tests that download toolchain/crates" +) +def test_toolchain_and_component(run_baker, work_dir): + cfg = os.path.join(work_dir, "rust.yml") + with open(cfg, "w") as f: + f.write(_yml("""\ +toolchain: "stable" +components: ["rustfmt"] +""")) + + r, log = _run_prebake(run_baker, cfg, timeout=600, + operation="rustup default stable + component add rustfmt", + expected="stable toolchain active, rustfmt installed") + + _check(r, log, "toolchain + rustfmt installation", + "rustup default stable succeeds; rustfmt available") + + assert binary_exists("/usr/bin/rustup"), "rustup not installed" + r2 = _query(["rustup", "which", "rustc"]) + assert r2["success"], "rustc not found via rustup which" + assert os.path.isfile(r2["stdout"].strip()), "rustc binary missing" + + +# ── cross-compilation targets ─────────────────────────────────────── + + +@pytest.mark.skipif( + "RUST_UPM_NETWORK" not in os.environ, + reason="set RUST_UPM_NETWORK=1 for tests that download toolchain/crates" +) +def test_cross_compile_target(run_baker, work_dir): + cfg = os.path.join(work_dir, "rust.yml") + with open(cfg, "w") as f: + f.write(_yml("""\ +toolchain: "stable" +targets: ["wasm32-unknown-unknown"] +""")) + + r, log = _run_prebake(run_baker, cfg, timeout=600, + operation="rustup target add wasm32-unknown-unknown", + expected="wasm32 target appears in installed list") + + _check(r, log, "cross-compilation target installation", + "wasm32-unknown-unknown is installed") + + r2 = _query(["rustup", "target", "list", "--installed"]) + assert r2["success"], "rustup target list failed" + assert "wasm32-unknown-unknown" in r2["stdout"], "wasm32 target missing" + + +# ── cargo fetch ───────────────────────────────────────────────────── + + +@pytest.mark.skipif( + "RUST_UPM_NETWORK" not in os.environ, + reason="set RUST_UPM_NETWORK=1 for tests that download toolchain/crates" +) +def test_cargo_fetch(run_baker, work_dir): + manifest = _cargo_toml(work_dir) + cfg = os.path.join(work_dir, "rust.yml") + with open(cfg, "w") as f: + f.write(_yml("""\ +toolchain: "stable" +cargo: + fetch: true + locked: true + manifests: ["{manifest}"] +""".format(manifest=manifest))) + + r, log = _run_prebake(run_baker, cfg, timeout=600, + operation="cargo fetch on minimal Cargo.toml", + expected="~/.cargo/registry/cache populated") + + _check(r, log, "cargo fetch dependency pre-fetch", + "cargo fetch succeeds, registry cache non-empty") + + cache = os.path.expanduser("~/.cargo/registry/cache") + assert os.path.isdir(cache), "registry/cache missing" + assert len(os.listdir(cache)) > 0, "registry/cache empty after fetch" + + +# ── cargo install ─────────────────────────────────────────────────── + + +@pytest.mark.skipif( + "RUST_UPM_NETWORK" not in os.environ, + reason="set RUST_UPM_NETWORK=1 for tests that download toolchain/crates" +) +def test_cargo_install(run_baker, work_dir): + cfg = os.path.join(work_dir, "rust.yml") + with open(cfg, "w") as f: + f.write(_yml("""\ +toolchain: "stable" +cargo: + packages: ["cargo-audit"] + locked: true +""")) + + r, log = _run_prebake(run_baker, cfg, timeout=1200, + operation="cargo install cargo-audit", + expected="cargo-audit binary in ~/.cargo/bin/") + + _check(r, log, "cargo install crate", + "cargo-audit compiled and installed") + + assert binary_exists( + os.path.expanduser("~/.cargo/bin/cargo-audit") + ), "cargo-audit not installed" + + +# ── full flow ─────────────────────────────────────────────────────── + + +@pytest.mark.skipif( + "RUST_UPM_NETWORK" not in os.environ, + reason="set RUST_UPM_NETWORK=1 for tests that download toolchain/crates" +) +def test_full_flow(run_baker, work_dir): + manifest = _cargo_toml(work_dir) + cfg = os.path.join(work_dir, "rust.yml") + with open(cfg, "w") as f: + f.write(_yml("""\ +toolchain: "stable" +components: ["rustfmt"] +targets: ["wasm32-unknown-unknown"] +cargo: + fetch: true + locked: true + manifests: ["{manifest}"] + packages: ["cargo-watch"] +""".format(manifest=manifest))) + + r, log = _run_prebake(run_baker, cfg, timeout=1200, + operation="full Rust UPM flow", + expected="all stages succeed") + + _check(r, log, "full Rust UPM end-to-end", + "toolchain + cross target + cargo fetch + cargo install all pass") + + assert binary_exists("/usr/bin/rustup") + r2 = _query(["rustup", "which", "rustc"]) + assert r2["success"] + r3 = _query(["rustup", "target", "list", "--installed"]) + assert "wasm32-unknown-unknown" in r3["stdout"] + cache = os.path.expanduser("~/.cargo/registry/cache") + assert os.path.isdir(cache) and len(os.listdir(cache)) > 0 + assert binary_exists(os.path.expanduser("~/.cargo/bin/cargo-watch")) diff --git a/workshop-baker/src/prebake/stage/hook.rs b/workshop-baker/src/prebake/stage/hook.rs index e88deb9..5bbaaa3 100644 --- a/workshop-baker/src/prebake/stage/hook.rs +++ b/workshop-baker/src/prebake/stage/hook.rs @@ -57,7 +57,7 @@ pub async fn hook( ctx: &ExecutionContext, event_tx: EventSender, ) -> Result<(), ExecutionError> { - dbg!(ctx); + // dbg!(ctx); let hook = match hook { Some(h) => h, None => { @@ -79,7 +79,7 @@ pub async fn hook( let result = engine .execute_command_with_delta(&hook_cmd.command, ctx, &event_tx, &deltactx) .await; - dbg!(&result); + // dbg!(&result); if let Err(e) = result { log::error!("Hook {} failed: {:?}", index, e); return Err(e); diff --git a/workshop-engine/src/types.rs b/workshop-engine/src/types.rs index 8ce26c3..0f1c521 100644 --- a/workshop-engine/src/types.rs +++ b/workshop-engine/src/types.rs @@ -20,13 +20,14 @@ pub struct ExecutionContext { pub standalone: bool, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct DeltaExecutionContext { pub working_dir: Option, pub env_vars: HashMap>, pub timeout: Option, } + impl Default for ExecutionContext { fn default() -> Self { Self { diff --git a/workshop-engine/src/upm.rs b/workshop-engine/src/upm.rs index eaea08d..54c3d21 100644 --- a/workshop-engine/src/upm.rs +++ b/workshop-engine/src/upm.rs @@ -2,6 +2,7 @@ use crate::types::EventSender; use async_trait::async_trait; use serde_yaml::Value; mod custom; +mod rust; use crate::error::DependencyError; use crate::types::ExecutionContext; @@ -44,7 +45,7 @@ pub trait UserPackageManager { /// Returns a list of all available user package managers. pub fn all_managers() -> Vec> { - vec![Box::new(custom::Custom)] + vec![Box::new(rust::Rust), Box::new(custom::Custom)] } /// Selects a user package manager by name. diff --git a/workshop-engine/src/upm/rust.rs b/workshop-engine/src/upm/rust.rs new file mode 100644 index 0000000..96b29b2 --- /dev/null +++ b/workshop-engine/src/upm/rust.rs @@ -0,0 +1,316 @@ +use async_trait::async_trait; +use serde::Deserialize; +use std::path::PathBuf; + +use crate::error::DependencyError; +use crate::types::{DeltaExecutionContext, Engine, EventSender, ExecutionContext}; +use crate::upm::UPMSysDeps; + +use super::UserPackageManager; + +/// Default manifest paths used when `manifests` is not specified +/// in the `[rust.cargo]` sub-config. +/// +/// The bootstrap stage creates `/workspace` as the standard project +/// root. Most Docker-based builds have a single `Cargo.toml` here. +fn default_manifests() -> Vec { + vec![PathBuf::from("/workspace/Cargo.toml")] +} + +#[derive(Clone)] +pub struct Rust; + +// ── RustConfig (root) ───────────────────────────────────────────── + +/// Top-level configuration for the `rust` UPM. +/// +/// ```yaml +/// rust: +/// toolchain: "nightly-2024-01-01" +/// components: ["clippy", "rustfmt"] +/// targets: ["wasm32-unknown-unknown"] +/// cargo: +/// fetch: true +/// locked: true +/// manifests: ["/workspace/Cargo.toml"] +/// packages: ["cargo-audit"] +/// ``` +#[derive(Debug, Clone, Deserialize)] +struct RustConfig { + /// Rust toolchain to activate via `rustup default`. + /// If unset, the existing default toolchain is left alone. + #[serde(default)] + toolchain: Option, + + /// Rustup components to install (e.g. `clippy`, `rustfmt`). + #[serde(default)] + components: Vec, + + /// Compilation targets for cross-compilation. + /// Each entry runs `rustup target add `. + #[serde(default)] + targets: Vec, + + /// Cargo sub-configuration (fetch, install, manifests, …). + /// Optional — omit if you only need rustup-level setup. + #[serde(default)] + cargo: CargoSubConfig, +} + +// ── Cargo sub-config ─────────────────────────────────────────────── + +/// Nested cargo configuration inside `rust`. +#[derive(Debug, Clone, Deserialize, Default)] +struct CargoSubConfig { + /// Crate names to install via `cargo install`. + #[serde(default)] + packages: Vec, + + /// Alternative registry URL (passed as `--registry`). + #[serde(default)] + registry: Option, + + /// Additional flags forwarded to `cargo install` + /// (e.g. `--force`, `--version 0.1.0`). + #[serde(default)] + args: Vec, + + /// One or more `Cargo.toml` paths for `cargo fetch`. + /// Each path spawns a separate `cargo fetch --manifest-path `. + #[serde(default = "default_manifests")] + manifests: Vec, + + /// When `true`, run `cargo fetch` before installing packages. + #[serde(default)] + fetch: bool, + + /// Require an up-to-date `Cargo.lock` (passes `--locked`). + #[serde(default)] + locked: bool, +} + +// ── UserPackageManager impl ──────────────────────────────────────── + +#[async_trait] +impl UserPackageManager for Rust { + fn name(&self) -> &'static str { + "rust" + } + + fn system_dependency(&self, _config: &serde_yaml::Value) -> UPMSysDeps { + UPMSysDeps::new(vec!["rustup".to_string()], false) + } + + async fn main( + &self, + config: &serde_yaml::Value, + ctx: &ExecutionContext, + event_tx: &EventSender, + ) -> Result<(), DependencyError> { + let cfg: RustConfig = serde_yaml::from_value(config.clone()).map_err(|e| { + log::error!("Failed to parse user.rust config: {}", e); + DependencyError::ParseError() + })?; + + let engine = Engine::new(); + let mut commands: Vec = Vec::new(); + + // ── 1. rustup default ───────────────────────── + if let Some(ref tc) = cfg.toolchain { + commands.push(format!("rustup default '{}'", tc)); + } + + // ── 2. rustup component add ────────────────────── + if !cfg.components.is_empty() { + commands.push(format!( + "rustup component add {}", + cfg.components.join(" ") + )); + } + + // ── 3. rustup target add ─────────────────────── + if !cfg.targets.is_empty() { + commands.push(format!( + "rustup target add {}", + cfg.targets.join(" ") + )); + } + + // ── 4. cargo fetch ─────────────────────────────────────── + // + // `cargo fetch` without --target only fetches host-target + // dependencies. For cross-compilation we must fetch once + // per (manifest, target) pair. + let cargo = &cfg.cargo; + if cargo.fetch { + let mut base = String::from("cargo fetch"); + if cargo.locked { + base.push_str(" --locked"); + } + for manifest in &cargo.manifests { + // Host target (no --target) + commands.push(format!( + "{} --manifest-path '{}'", + base, + manifest.display() + )); + // Cross-compilation targets + for target in &cfg.targets { + commands.push(format!( + "{} --target '{}' --manifest-path '{}'", + base, + target, + manifest.display() + )); + } + } + } + + // ── 5. cargo install ───────────────────────────────────── + if !cargo.packages.is_empty() { + let mut cmd = String::from("cargo install"); + + if cargo.locked { + cmd.push_str(" --locked"); + } + if let Some(ref registry) = cargo.registry { + cmd.push_str(&format!(" --registry '{}'", registry)); + } + for a in &cargo.args { + cmd.push(' '); + cmd.push_str(a); + } + for pkg in &cargo.packages { + cmd.push(' '); + cmd.push_str(pkg); + } + commands.push(cmd); + } + + // ── Execute ────────────────────────────────────────────── + for cmd in &commands { + log::info!("Rust UPM executing: {}", cmd); + let result = engine + .execute_command_with_delta( + cmd, + ctx, + event_tx, + &DeltaExecutionContext::default(), + ) + .await; + if let Err(e) = result { + log::error!("Rust command failed: {}", e); + return Err(DependencyError::CustomFailed(e)); + } + } + + if commands.is_empty() { + log::warn!("Rust UPM configured with nothing to do"); + } + + Ok(()) + } +} + +// ── Tests ────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_name() { + assert_eq!(Rust.name(), "rust"); + } + + #[test] + fn test_system_dependency() { + let deps = Rust.system_dependency(&serde_yaml::Value::Null); + assert_eq!(deps.packages, vec!["rustup"]); + assert!(!deps.mapped); + } + + #[test] + fn test_parse_empty() { + let yaml = "{}"; + let cfg: RustConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(cfg.toolchain.is_none()); + assert!(cfg.components.is_empty()); + assert!(cfg.targets.is_empty()); + assert!(!cfg.cargo.fetch); + } + + #[test] + fn test_parse_rustup_only() { + let yaml = r#" +toolchain: "nightly-2024-01-01" +components: ["clippy", "rustfmt"] +targets: ["wasm32-unknown-unknown", "x86_64-unknown-linux-musl"] +"#; + let cfg: RustConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(cfg.toolchain.as_deref(), Some("nightly-2024-01-01")); + assert_eq!(cfg.components, vec!["clippy", "rustfmt"]); + assert_eq!( + cfg.targets, + vec!["wasm32-unknown-unknown", "x86_64-unknown-linux-musl"] + ); + assert!(!cfg.cargo.fetch); + } + + #[test] + fn test_parse_cargo_subconfig() { + let yaml = r#" +components: ["clippy"] +cargo: + fetch: true + locked: true + manifests: + - "/workspace/foo/Cargo.toml" + - "/workspace/bar/Cargo.toml" + packages: ["cargo-audit"] + registry: "https://mirrors.tuna.tsinghua.edu.cn/crates.io-index" +"#; + let cfg: RustConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(cfg.components, vec!["clippy"]); + + let c = &cfg.cargo; + assert!(c.fetch); + assert!(c.locked); + assert_eq!( + c.manifests, + vec![ + PathBuf::from("/workspace/foo/Cargo.toml"), + PathBuf::from("/workspace/bar/Cargo.toml"), + ] + ); + assert_eq!(c.packages, vec!["cargo-audit"]); + assert_eq!( + c.registry.as_deref(), + Some("https://mirrors.tuna.tsinghua.edu.cn/crates.io-index") + ); + } + + #[test] + fn test_cargo_defaults() { + let yaml = r#" +components: ["rustfmt"] +cargo: {} +"#; + let cfg: RustConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(cfg.components, vec!["rustfmt"]); + assert!(!cfg.cargo.fetch); + assert!(!cfg.cargo.locked); + assert_eq!( + cfg.cargo.manifests, + vec![PathBuf::from("/workspace/Cargo.toml")] + ); + } + + #[test] + fn test_default_manifests() { + assert_eq!( + default_manifests(), + vec![PathBuf::from("/workspace/Cargo.toml")] + ); + } +}