Compare commits
4 Commits
cf317b55c6
...
681b7d1333
| Author | SHA1 | Date | |
|---|---|---|---|
| 681b7d1333 | |||
| f07588a716 | |||
| b0530fb858 | |||
| fd18b547e5 |
@@ -18,3 +18,5 @@ tests/**/__pycache__/
|
||||
tests/.pytest_cache/
|
||||
tests/llm/.cache/
|
||||
*.pyc
|
||||
.staging
|
||||
.pytest_cache
|
||||
|
||||
@@ -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:<name> | pm:<name>
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+33
-17
@@ -77,29 +77,45 @@ 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,
|
||||
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,
|
||||
timeout=timeout,
|
||||
)
|
||||
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,
|
||||
|
||||
+1
@@ -9,6 +9,7 @@ bootstrap:
|
||||
dependencies:
|
||||
system:
|
||||
pacman:
|
||||
mirror: "https://mirrors.tuna.tsinghua.edu.cn/archlinux/$repo/os/$arch"
|
||||
packages:
|
||||
- "curl"
|
||||
- "wget"
|
||||
|
||||
+1
-3
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Go UPM system tests — go install + go mod download via bare prebake."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import pytest
|
||||
from probes import binary_exists
|
||||
|
||||
|
||||
def _yml(actions_yaml):
|
||||
return """\
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
security:
|
||||
drop_after: never
|
||||
dependencies:
|
||||
config:
|
||||
repology_endpoint: none
|
||||
system:
|
||||
pacman:
|
||||
packages:
|
||||
user:
|
||||
go:
|
||||
""" + _indent(actions_yaml, 6)
|
||||
|
||||
|
||||
def _indent(text, spaces):
|
||||
pad = " " * spaces
|
||||
return "\n".join(pad + line if line.strip() else line
|
||||
for line in text.splitlines())
|
||||
|
||||
|
||||
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):
|
||||
r = run_baker("prebake", config_path, timeout=timeout,
|
||||
env={"RUST_LOG": os.environ.get("RUST_LOG", "info")})
|
||||
full_log = "[stdout]\n{}\n[stderr]\n{}".format(r["stdout"], r["stderr"])
|
||||
return r, full_log
|
||||
|
||||
|
||||
def _check(r, full_log, operation, expected):
|
||||
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))
|
||||
|
||||
|
||||
def _gopath_bin(name):
|
||||
"""Return the expected $GOPATH/bin path or $HOME/go/bin fallback."""
|
||||
gopath = os.environ.get("GOPATH", "")
|
||||
home = os.environ.get("HOME", "/root")
|
||||
if not gopath:
|
||||
gopath = os.path.join(home, "go")
|
||||
return os.path.join(gopath, "bin", name)
|
||||
|
||||
|
||||
# ── go install ──────────────────────────────────────────────────────
|
||||
|
||||
def test_go_install(run_baker, work_dir):
|
||||
"""go install a small known tool, verify binary exists."""
|
||||
cfg = os.path.join(work_dir, "go.yml")
|
||||
with open(cfg, "w") as f:
|
||||
f.write(_yml("""\
|
||||
proxy: "https://goproxy.cn,direct"
|
||||
packages: ["golang.org/x/tools/cmd/goimports@latest"]
|
||||
"""))
|
||||
|
||||
r, log = _run_prebake(run_baker, cfg, timeout=300,
|
||||
operation="go install goimports",
|
||||
expected="goimports binary appears in GOPATH/bin")
|
||||
_check(r, log, "go install", "goimports installed")
|
||||
|
||||
bin_path = _gopath_bin("goimports")
|
||||
assert binary_exists(bin_path), f"goimports not found at {bin_path}"
|
||||
|
||||
|
||||
# ── go mod download ─────────────────────────────────────────────────
|
||||
|
||||
def test_go_mod_download(run_baker, work_dir):
|
||||
"""Create a minimal go.mod, download deps, verify cache populated."""
|
||||
mod_dir = os.path.join(work_dir, "testmod")
|
||||
os.makedirs(mod_dir, exist_ok=True)
|
||||
with open(os.path.join(mod_dir, "go.mod"), "w") as f:
|
||||
f.write("module test\n\ngo 1.21\n\nrequire golang.org/x/text v0.14.0\n")
|
||||
|
||||
cfg = os.path.join(work_dir, "go.yml")
|
||||
with open(cfg, "w") as f:
|
||||
f.write(_yml("""\
|
||||
proxy: "https://goproxy.cn,direct"
|
||||
modules: ["{mod}"]
|
||||
""".format(mod=os.path.join(mod_dir, "go.mod"))))
|
||||
|
||||
r, log = _run_prebake(run_baker, cfg, timeout=180,
|
||||
operation="go mod download",
|
||||
expected="GOMODCACHE has x/text cached")
|
||||
_check(r, log, "go mod download", "dependencies downloaded to module cache")
|
||||
|
||||
# Check GOMODCACHE or default $GOPATH/pkg/mod
|
||||
gomodcache = os.environ.get("GOMODCACHE", "")
|
||||
if not gomodcache:
|
||||
gopath = os.environ.get("GOPATH",
|
||||
os.path.join(os.environ.get("HOME", "/root"), "go"))
|
||||
gomodcache = os.path.join(gopath, "pkg", "mod")
|
||||
cache_dir = os.path.join(gomodcache, "golang.org", "x")
|
||||
assert os.path.isdir(cache_dir), f"no module cache at {cache_dir}"
|
||||
|
||||
|
||||
# ── go install + mod download ───────────────────────────────────────
|
||||
|
||||
def test_go_full(run_baker, work_dir):
|
||||
"""go install a tool AND download project deps."""
|
||||
mod_dir = os.path.join(work_dir, "testmod2")
|
||||
os.makedirs(mod_dir, exist_ok=True)
|
||||
with open(os.path.join(mod_dir, "go.mod"), "w") as f:
|
||||
f.write("module test\n\ngo 1.21\n")
|
||||
|
||||
cfg = os.path.join(work_dir, "go.yml")
|
||||
with open(cfg, "w") as f:
|
||||
f.write(_yml("""\
|
||||
proxy: "https://goproxy.cn,direct"
|
||||
packages: ["golang.org/x/tools/cmd/goimports@latest"]
|
||||
modules: ["{mod}"]
|
||||
env:
|
||||
GOMODCACHE: "/tmp/gocache"
|
||||
""".format(mod=os.path.join(mod_dir, "go.mod"))))
|
||||
|
||||
r, log = _run_prebake(run_baker, cfg, timeout=300,
|
||||
operation="go install + go mod download",
|
||||
expected="goimports installed, module cache populated")
|
||||
_check(r, log, "go full", "tool installed + deps downloaded")
|
||||
|
||||
assert binary_exists(_gopath_bin("goimports")), "goimports missing"
|
||||
assert os.path.isdir("/tmp/gocache"), "GOMODCACHE not created"
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Python UPM system tests — pip, uv, poetry, pipx via bare prebake."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from probes import binary_exists
|
||||
|
||||
RUST_LOG = os.environ.get("RUST_LOG", "info")
|
||||
|
||||
|
||||
def _yml(actions_yaml):
|
||||
return """\
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
security:
|
||||
drop_after: never
|
||||
dependencies:
|
||||
config:
|
||||
repology_endpoint: none
|
||||
system:
|
||||
pacman:
|
||||
packages:
|
||||
user:
|
||||
python:
|
||||
""" + _indent(actions_yaml, 6)
|
||||
|
||||
|
||||
def _indent(text, spaces):
|
||||
pad = " " * spaces
|
||||
return "\n".join(pad + line if line.strip() else line
|
||||
for line in text.splitlines())
|
||||
|
||||
|
||||
def _query(cmd, timeout=60):
|
||||
import subprocess
|
||||
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):
|
||||
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):
|
||||
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))
|
||||
|
||||
|
||||
# ── pip ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_pip_install_package(run_baker, work_dir):
|
||||
"""Install a package via pip."""
|
||||
cfg = os.path.join(work_dir, "python.yml")
|
||||
with open(cfg, "w") as f:
|
||||
f.write(_yml("""\
|
||||
- type: pip
|
||||
packages: [requests]
|
||||
index: "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
|
||||
args: ["--break-system-packages"]
|
||||
"""))
|
||||
|
||||
r, log = _run_prebake(run_baker, cfg, timeout=120,
|
||||
operation="pip install requests", expected="requests module importable")
|
||||
_check(r, log, "pip install", "pip installs requests successfully")
|
||||
|
||||
r2 = _query(["python3", "-c", "import requests"])
|
||||
assert r2["success"], f"import requests failed: {r2['stderr']}"
|
||||
|
||||
|
||||
# ── pip + venv ──────────────────────────────────────────────────────
|
||||
|
||||
def test_pip_venv_install(run_baker, work_dir):
|
||||
"""Create venv and install inside it."""
|
||||
cfg = os.path.join(work_dir, "python.yml")
|
||||
with open(cfg, "w") as f:
|
||||
f.write(_yml("""\
|
||||
- type: pip
|
||||
directory: "/tmp/test-venv"
|
||||
index: "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
|
||||
packages: [requests]
|
||||
"""))
|
||||
|
||||
r, log = _run_prebake(run_baker, cfg, timeout=120,
|
||||
operation="venv + pip install",
|
||||
expected="requests installed inside venv")
|
||||
_check(r, log, "pip venv install", "venv created, requests installed")
|
||||
|
||||
r2 = _query(["/tmp/test-venv/bin/python3", "-c", "import requests"])
|
||||
assert r2["success"], f"import from venv failed: {r2['stderr']}"
|
||||
|
||||
|
||||
# ── pip + requirements ──────────────────────────────────────────────
|
||||
|
||||
def test_pip_requirements_file(run_baker, work_dir):
|
||||
"""Install from a requirements.txt file."""
|
||||
req_path = os.path.join(work_dir, "requirements.txt")
|
||||
with open(req_path, "w") as f:
|
||||
f.write("requests\n")
|
||||
|
||||
cfg = os.path.join(work_dir, "python.yml")
|
||||
with open(cfg, "w") as f:
|
||||
f.write(_yml("""\
|
||||
- type: pip
|
||||
directory: "/tmp/test-venv2"
|
||||
index: "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
|
||||
manifest: "{}"
|
||||
""".format(req_path)))
|
||||
|
||||
r, log = _run_prebake(run_baker, cfg, timeout=120,
|
||||
operation="pip install -r requirements.txt",
|
||||
expected="requests installed from requirements file")
|
||||
_check(r, log, "pip -r requirements.txt", "pip installs from requirements file")
|
||||
|
||||
r2 = _query(["/tmp/test-venv2/bin/python3", "-c", "import requests"])
|
||||
assert r2["success"], f"import from venv failed: {r2['stderr']}"
|
||||
|
||||
|
||||
# ── multiple actions ────────────────────────────────────────────────
|
||||
|
||||
def test_multiple_actions(run_baker, work_dir):
|
||||
"""Two pip actions in one config."""
|
||||
cfg = os.path.join(work_dir, "python.yml")
|
||||
with open(cfg, "w") as f:
|
||||
f.write(_yml("""\
|
||||
- type: pip
|
||||
directory: "/tmp/test-venv-a"
|
||||
index: "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
|
||||
packages: [requests]
|
||||
args: ["--break-system-packages"]
|
||||
- type: pip
|
||||
directory: "/tmp/test-venv-b"
|
||||
index: "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
|
||||
packages: [pytest]
|
||||
"""))
|
||||
|
||||
r, log = _run_prebake(run_baker, cfg, timeout=180,
|
||||
operation="two pip actions",
|
||||
expected="both venvs have their packages")
|
||||
_check(r, log, "multiple pip actions", "both requests and pytest installed")
|
||||
|
||||
r2 = _query(["/tmp/test-venv-a/bin/python3", "-c", "import requests"])
|
||||
assert r2["success"], f"venv-a missing requests: {r2['stderr']}"
|
||||
|
||||
r3 = _query(["/tmp/test-venv-b/bin/python3", "-c", "import pytest"])
|
||||
assert r3["success"], f"venv-b missing pytest: {r3['stderr']}"
|
||||
@@ -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"))
|
||||
@@ -154,7 +154,8 @@ pub async fn prebake(
|
||||
.map(|c| c.repology_endpoint)
|
||||
.unwrap_or(workshop_engine::RepologyEndpoint::Default);
|
||||
|
||||
let pm_name = dependencies.system
|
||||
let pm_name = dependencies
|
||||
.system
|
||||
.as_ref()
|
||||
.and_then(|sys| sys.keys().next().cloned());
|
||||
|
||||
|
||||
@@ -64,4 +64,4 @@ pub enum BootstrapError {
|
||||
|
||||
#[error("Invalid bootstrap configuration: {0}")]
|
||||
InvalidConfig(String),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,10 @@ pub async fn bootstrap(
|
||||
match BootstrapSource::parse(&raw) {
|
||||
BootstrapSource::Inline(script) => {
|
||||
if ctx.dry_run {
|
||||
log::info!("[DRY_RUN] User provided inline bootstrap script:\n{}", script);
|
||||
log::info!(
|
||||
"[DRY_RUN] User provided inline bootstrap script:\n{}",
|
||||
script
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
log::debug!("Using inline bootstrap script ({} bytes)", script.len());
|
||||
@@ -86,13 +89,16 @@ pub async fn bootstrap(
|
||||
// Should support: checksum verification, retry, timeout.
|
||||
log::info!("Custom bootstrap from URL: {}", url);
|
||||
log::info!(" Target: {}", bootstrap_path.display());
|
||||
unimplemented!("HTTP bootstrap fetching requires bakerd-level network infrastructure");
|
||||
unimplemented!(
|
||||
"HTTP bootstrap fetching requires bakerd-level network infrastructure"
|
||||
);
|
||||
}
|
||||
BootstrapSource::File(path) => {
|
||||
// Local file copy. This is the simplest case — we can implement
|
||||
// this directly without bakerd infrastructure.
|
||||
log::info!("Custom bootstrap from local file: {}", path);
|
||||
tokio::fs::copy(&path, &bootstrap_path).await
|
||||
tokio::fs::copy(&path, &bootstrap_path)
|
||||
.await
|
||||
.map_err(BootstrapError::IOError)?;
|
||||
}
|
||||
}
|
||||
@@ -166,7 +172,8 @@ impl BootstrapSource {
|
||||
if let Some(name) = raw.strip_prefix("server://") {
|
||||
return BootstrapSource::Server(name.to_string());
|
||||
}
|
||||
if let Some(url) = raw.strip_prefix("https://")
|
||||
if let Some(url) = raw
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| raw.strip_prefix("http://"))
|
||||
{
|
||||
return BootstrapSource::Http(url.to_string());
|
||||
@@ -325,9 +332,7 @@ fn generate_bootstrap(
|
||||
///
|
||||
/// Returns `Some(Box<dyn PackageManager>)` if a configured package manager is known,
|
||||
/// or `None` if no package manager is configured or recognized.
|
||||
fn get_package_manager(
|
||||
config: &PrebakeConfig,
|
||||
) -> Option<Box<dyn pm::PackageManager>> {
|
||||
fn get_package_manager(config: &PrebakeConfig) -> Option<Box<dyn pm::PackageManager>> {
|
||||
config
|
||||
.dependencies
|
||||
.as_ref()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -14,7 +14,6 @@ pub trait PackageManager {
|
||||
/// Returns the name of the package manager.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
|
||||
/// Returns the binary name of the package manager.
|
||||
fn binary(&self) -> &'static str;
|
||||
|
||||
@@ -24,7 +23,6 @@ pub trait PackageManager {
|
||||
/// Generates commands to install the specified packages.
|
||||
fn install(&self, package: &[String]) -> Vec<Command>;
|
||||
|
||||
|
||||
/// Generates commands to change the primary mirror repository.
|
||||
fn change_mirror(&self, repo: &str, osinfo: Option<&Info>) -> Vec<Command>;
|
||||
|
||||
@@ -42,7 +40,6 @@ pub fn all_managers() -> Vec<Box<dyn PackageManager>> {
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
pub fn select(manager: &str) -> Option<Box<dyn PackageManager>> {
|
||||
all_managers().into_iter().find(|pm| pm.name() == manager)
|
||||
}
|
||||
|
||||
@@ -73,10 +73,8 @@ impl PackageManager for Apk {
|
||||
// Download and trust GPG key if provided
|
||||
if let Some(key_url) = &repo_cfg.key_url {
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c").arg(format!(
|
||||
"wget -qO- '{}' | apk keys --stdin",
|
||||
key_url
|
||||
));
|
||||
cmd.arg("-c")
|
||||
.arg(format!("wget -qO- '{}' | apk keys --stdin", key_url));
|
||||
cmds.push(cmd);
|
||||
}
|
||||
|
||||
@@ -87,10 +85,8 @@ impl PackageManager for Apk {
|
||||
format!("{}\n", repo_cfg.url)
|
||||
};
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c").arg(format!(
|
||||
"echo '{}' >> /etc/apk/repositories",
|
||||
line.trim()
|
||||
));
|
||||
cmd.arg("-c")
|
||||
.arg(format!("echo '{}' >> /etc/apk/repositories", line.trim()));
|
||||
cmds.push(cmd);
|
||||
}
|
||||
|
||||
@@ -206,9 +202,7 @@ mod tests {
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("key_url".to_string()),
|
||||
serde_yaml::Value::String(
|
||||
"https://example.com/alpine-key".to_string(),
|
||||
),
|
||||
serde_yaml::Value::String("https://example.com/alpine-key".to_string()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -238,8 +232,7 @@ mod tests {
|
||||
(
|
||||
serde_yaml::Value::String("url".to_string()),
|
||||
serde_yaml::Value::String(
|
||||
"https://dl-cdn.alpinelinux.org/alpine/v3.19/community"
|
||||
.to_string(),
|
||||
"https://dl-cdn.alpinelinux.org/alpine/v3.19/community".to_string(),
|
||||
),
|
||||
),
|
||||
(
|
||||
|
||||
@@ -209,9 +209,7 @@ fn extract_codename(info: Option<&Info>) -> Option<String> {
|
||||
|
||||
// 2. Try to infer from version string
|
||||
match info.version() {
|
||||
Version::Custom(v)
|
||||
if !v.chars().next().map(|c| c.is_numeric()).unwrap_or(true)
|
||||
=> {
|
||||
Version::Custom(v) if !v.chars().next().map(|c| c.is_numeric()).unwrap_or(true) => {
|
||||
// Some systems put codename in Custom field
|
||||
return Some(v.clone());
|
||||
}
|
||||
@@ -296,9 +294,7 @@ mod tests {
|
||||
[
|
||||
(
|
||||
serde_yaml::Value::String("url".to_string()),
|
||||
serde_yaml::Value::String(
|
||||
"https://repo.example.com/ubuntu".to_string(),
|
||||
),
|
||||
serde_yaml::Value::String("https://repo.example.com/ubuntu".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("distro".to_string()),
|
||||
@@ -340,9 +336,7 @@ mod tests {
|
||||
[
|
||||
(
|
||||
serde_yaml::Value::String("url".to_string()),
|
||||
serde_yaml::Value::String(
|
||||
"https://repo.example.com/ubuntu".to_string(),
|
||||
),
|
||||
serde_yaml::Value::String("https://repo.example.com/ubuntu".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("distro".to_string()),
|
||||
@@ -350,9 +344,7 @@ mod tests {
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("key_url".to_string()),
|
||||
serde_yaml::Value::String(
|
||||
"https://repo.example.com/key.gpg".to_string(),
|
||||
),
|
||||
serde_yaml::Value::String("https://repo.example.com/key.gpg".to_string()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -377,8 +369,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_repo_name() {
|
||||
assert_eq!(sanitize_repo_name("https://example.com/repo"), "example.com_repo");
|
||||
assert_eq!(sanitize_repo_name("http://mirror.tuna.tsinghua.edu.cn/ubuntu"), "mirror.tuna.tsinghua.edu.cn_ubuntu");
|
||||
assert_eq!(
|
||||
sanitize_repo_name("https://example.com/repo"),
|
||||
"example.com_repo"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_repo_name("http://mirror.tuna.tsinghua.edu.cn/ubuntu"),
|
||||
"mirror.tuna.tsinghua.edu.cn_ubuntu"
|
||||
);
|
||||
assert_eq!(sanitize_repo_name("ppa:user/name"), "user_name");
|
||||
}
|
||||
|
||||
@@ -401,9 +399,7 @@ mod tests {
|
||||
serde_yaml::Value::Mapping(
|
||||
[(
|
||||
serde_yaml::Value::String("url".to_string()),
|
||||
serde_yaml::Value::String(
|
||||
"https://repo.example.com/ubuntu".to_string(),
|
||||
),
|
||||
serde_yaml::Value::String("https://repo.example.com/ubuntu".to_string()),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
|
||||
@@ -96,17 +96,17 @@ impl PackageManager for Dnf {
|
||||
|
||||
// Download and import the GPG key
|
||||
let mut key_cmd = Command::new("sh");
|
||||
key_cmd.arg("-c").arg(format!(
|
||||
"rpm --import '{}'",
|
||||
gpgkey
|
||||
));
|
||||
key_cmd.arg("-c").arg(format!("rpm --import '{}'", gpgkey));
|
||||
cmds.push(key_cmd);
|
||||
} else {
|
||||
content.push_str("gpgcheck=0\n");
|
||||
}
|
||||
|
||||
// Write the .repo file
|
||||
let repo_path = format!("/etc/yum.repos.d/{}.repo", sanitize_repo_name(&repo_cfg.name));
|
||||
let repo_path = format!(
|
||||
"/etc/yum.repos.d/{}.repo",
|
||||
sanitize_repo_name(&repo_cfg.name)
|
||||
);
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c").arg(format!(
|
||||
"cat > '{}' << 'DNF_EOF'\n{}DNF_EOF",
|
||||
@@ -200,9 +200,7 @@ mod tests {
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("baseurl".to_string()),
|
||||
serde_yaml::Value::String(
|
||||
"https://repo.example.com/fedora".to_string(),
|
||||
),
|
||||
serde_yaml::Value::String("https://repo.example.com/fedora".to_string()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -236,15 +234,11 @@ mod tests {
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("baseurl".to_string()),
|
||||
serde_yaml::Value::String(
|
||||
"https://repo.example.com/fedora".to_string(),
|
||||
),
|
||||
serde_yaml::Value::String("https://repo.example.com/fedora".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("gpgkey".to_string()),
|
||||
serde_yaml::Value::String(
|
||||
"https://repo.example.com/gpg".to_string(),
|
||||
),
|
||||
serde_yaml::Value::String("https://repo.example.com/gpg".to_string()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
|
||||
@@ -77,7 +77,6 @@ impl PackageManager for Pacman {
|
||||
"pacman"
|
||||
}
|
||||
|
||||
|
||||
fn binary(&self) -> &'static str {
|
||||
"pacman"
|
||||
}
|
||||
@@ -198,7 +197,6 @@ mod tests {
|
||||
assert_eq!(pacman.binary(), "pacman");
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn test_update_command_contents() {
|
||||
let cmds = Pacman.update();
|
||||
|
||||
@@ -20,7 +20,7 @@ pub struct ExecutionContext {
|
||||
pub standalone: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DeltaExecutionContext {
|
||||
pub working_dir: Option<PathBuf>,
|
||||
pub env_vars: HashMap<String, Option<String>>,
|
||||
|
||||
@@ -2,6 +2,9 @@ use crate::types::EventSender;
|
||||
use async_trait::async_trait;
|
||||
use serde_yaml::Value;
|
||||
mod custom;
|
||||
mod go;
|
||||
mod python;
|
||||
mod rust;
|
||||
|
||||
use crate::error::DependencyError;
|
||||
use crate::types::ExecutionContext;
|
||||
@@ -44,7 +47,12 @@ pub trait UserPackageManager {
|
||||
|
||||
/// Returns a list of all available user package managers.
|
||||
pub fn all_managers() -> Vec<Box<dyn UserPackageManager>> {
|
||||
vec![Box::new(custom::Custom)]
|
||||
vec![
|
||||
Box::new(rust::Rust),
|
||||
Box::new(python::Python),
|
||||
Box::new(go::Go),
|
||||
Box::new(custom::Custom),
|
||||
]
|
||||
}
|
||||
|
||||
/// Selects a user package manager by name.
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::error::DependencyError;
|
||||
use crate::types::{DeltaExecutionContext, Engine, EventSender, ExecutionContext};
|
||||
use crate::upm::UPMSysDeps;
|
||||
|
||||
use super::UserPackageManager;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Go;
|
||||
|
||||
fn default_go_type() -> String {
|
||||
"go".to_string()
|
||||
}
|
||||
|
||||
// ── GoConfig ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct GoConfig {
|
||||
/// UPM backend type (currently only `"go"`).
|
||||
#[serde(default = "default_go_type", rename = "type")]
|
||||
type_: String,
|
||||
|
||||
/// Go module proxy URL (sets `GOPROXY`).
|
||||
#[serde(default)]
|
||||
proxy: Option<String>,
|
||||
|
||||
/// Packages to install via `go install`.
|
||||
/// Use `@version` suffix, e.g. `golang.org/x/tools/cmd/goimports@latest`.
|
||||
#[serde(default)]
|
||||
packages: Vec<String>,
|
||||
|
||||
/// `go.mod` paths for `go mod download`.
|
||||
/// Each path's directory is `cd`'d into before downloading.
|
||||
#[serde(default)]
|
||||
modules: Vec<PathBuf>,
|
||||
|
||||
/// Environment variables injected into child processes.
|
||||
#[serde(default, rename = "env")]
|
||||
env_: HashMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
// ── UserPackageManager impl ─────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl UserPackageManager for Go {
|
||||
fn name(&self) -> &'static str {
|
||||
"go"
|
||||
}
|
||||
|
||||
fn system_dependency(&self, _config: &serde_yaml::Value) -> UPMSysDeps {
|
||||
UPMSysDeps::new(vec!["go".to_string()], false)
|
||||
}
|
||||
|
||||
async fn main(
|
||||
&self,
|
||||
config: &serde_yaml::Value,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
) -> Result<(), DependencyError> {
|
||||
let cfg: GoConfig = serde_yaml::from_value(config.clone()).map_err(|e| {
|
||||
log::error!("Failed to parse user.go config: {}", e);
|
||||
DependencyError::ParseError()
|
||||
})?;
|
||||
|
||||
let engine = Engine::new();
|
||||
let mut delta_env = cfg.env_.clone();
|
||||
|
||||
// Inject GOPROXY if configured
|
||||
if let Some(ref proxy) = cfg.proxy {
|
||||
delta_env.insert("GOPROXY".into(), Some(proxy.clone()));
|
||||
}
|
||||
|
||||
let delta = DeltaExecutionContext {
|
||||
env_vars: delta_env,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// ── go mod download ──────────────────────────────────────
|
||||
for mod_path in &cfg.modules {
|
||||
let dir = mod_path.parent().unwrap_or(mod_path);
|
||||
let cmd = "go mod download".to_string();
|
||||
log::info!("Go UPM executing: cd {} && {}", dir.display(), cmd);
|
||||
|
||||
let cd_delta = DeltaExecutionContext {
|
||||
working_dir: Some(dir.to_path_buf()),
|
||||
env_vars: delta.env_vars.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = engine
|
||||
.execute_command_with_delta(&cmd, ctx, event_tx, &cd_delta)
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
log::error!("go mod download failed in {}: {}", dir.display(), e);
|
||||
return Err(DependencyError::CustomFailed(e));
|
||||
}
|
||||
}
|
||||
|
||||
// ── go install ──────────────────────────────────────────
|
||||
if !cfg.packages.is_empty() {
|
||||
let cmd = format!("go install {}", cfg.packages.join(" "));
|
||||
log::info!("Go UPM executing: {}", cmd);
|
||||
let result = engine
|
||||
.execute_command_with_delta(&cmd, ctx, event_tx, &delta)
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
log::error!("Go install failed: {}", e);
|
||||
return Err(DependencyError::CustomFailed(e));
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.modules.is_empty() && cfg.packages.is_empty() {
|
||||
log::warn!("Go UPM configured with nothing to do");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_name() {
|
||||
assert_eq!(Go.name(), "go");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_dependency() {
|
||||
let deps = Go.system_dependency(&serde_yaml::Value::Null);
|
||||
assert_eq!(deps.packages, vec!["go"]);
|
||||
assert!(!deps.mapped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_minimal() {
|
||||
let yaml = r#"
|
||||
packages: ["golang.org/x/tools/cmd/goimports@latest"]
|
||||
"#;
|
||||
let cfg: GoConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert!(cfg.proxy.is_none());
|
||||
assert_eq!(cfg.packages.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_full() {
|
||||
let yaml = r#"
|
||||
proxy: "https://goproxy.cn,direct"
|
||||
packages: ["honnef.co/go/tools/cmd/staticcheck@latest"]
|
||||
modules:
|
||||
- "/workspace/server/go.mod"
|
||||
env:
|
||||
GOMODCACHE: "/tmp/gocache"
|
||||
"#;
|
||||
let cfg: GoConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(cfg.proxy.as_deref(), Some("https://goproxy.cn,direct"));
|
||||
assert_eq!(
|
||||
cfg.packages,
|
||||
vec!["honnef.co/go/tools/cmd/staticcheck@latest"]
|
||||
);
|
||||
assert_eq!(cfg.modules, vec![PathBuf::from("/workspace/server/go.mod")]);
|
||||
assert_eq!(
|
||||
cfg.env_.get("GOMODCACHE").and_then(|v| v.as_deref()),
|
||||
Some("/tmp/gocache"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::error::DependencyError;
|
||||
use crate::types::{DeltaExecutionContext, Engine, EventSender, ExecutionContext};
|
||||
use crate::upm::UPMSysDeps;
|
||||
|
||||
use super::UserPackageManager;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Python;
|
||||
|
||||
fn default_type() -> String {
|
||||
"pip".to_string()
|
||||
}
|
||||
|
||||
// ── PythonAction (one entry in the list) ────────────────────────────
|
||||
|
||||
/// A single Python package-management operation.
|
||||
///
|
||||
/// `type` selects the backend: ``pip``, ``uv``, ``poetry``, or ``pipx``.
|
||||
/// Everything else applies to that backend's invocation.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct PythonAction {
|
||||
/// Backend tool name.
|
||||
#[serde(default = "default_type", rename = "type")]
|
||||
type_: String,
|
||||
|
||||
/// Python interpreter to use (default: system ``python3``).
|
||||
#[serde(default)]
|
||||
python: Option<String>,
|
||||
|
||||
/// Virtualenv directory — created if set.
|
||||
#[serde(default)]
|
||||
directory: Option<PathBuf>,
|
||||
|
||||
/// PyPI index URL (``-i`` / ``--index-url``).
|
||||
#[serde(default)]
|
||||
index: Option<String>,
|
||||
|
||||
/// Package names.
|
||||
#[serde(default)]
|
||||
packages: Vec<String>,
|
||||
|
||||
/// Requirements file or pyproject.toml.
|
||||
#[serde(default)]
|
||||
manifest: Option<PathBuf>,
|
||||
|
||||
/// Extra CLI arguments forwarded to the backend.
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
|
||||
/// Environment variables injected into the child process.
|
||||
#[serde(default, rename = "env")]
|
||||
env_: HashMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
// ── PythonConfig (top-level, transparent vec) ───────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
struct PythonConfig(Vec<PythonAction>);
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
fn python_bin(action: &PythonAction) -> &str {
|
||||
action.python.as_deref().unwrap_or("python3")
|
||||
}
|
||||
|
||||
fn index_flag(action: &PythonAction) -> String {
|
||||
match &action.index {
|
||||
Some(idx) => format!(" -i '{}'", idx),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn env_delta(action: &PythonAction) -> DeltaExecutionContext {
|
||||
DeltaExecutionContext {
|
||||
env_vars: action.env_.clone(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn venv_pip(action: &PythonAction) -> String {
|
||||
match &action.directory {
|
||||
Some(dir) => format!("{}/bin/pip", dir.display()),
|
||||
None => format!("{} -m pip", python_bin(action)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── UserPackageManager impl ─────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl UserPackageManager for Python {
|
||||
fn name(&self) -> &'static str {
|
||||
"python"
|
||||
}
|
||||
|
||||
fn system_dependency(&self, config: &serde_yaml::Value) -> UPMSysDeps {
|
||||
let mut pkgs = vec!["python3".to_string()];
|
||||
|
||||
if let Ok(cfg) = serde_yaml::from_value::<PythonConfig>(config.clone()) {
|
||||
for a in &cfg.0 {
|
||||
match a.type_.as_str() {
|
||||
"uv" => pkgs.push("uv".into()),
|
||||
"poetry" => pkgs.push("poetry".into()),
|
||||
"pipx" => pkgs.push("pipx".into()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pkgs.sort();
|
||||
pkgs.dedup();
|
||||
}
|
||||
|
||||
UPMSysDeps::new(pkgs, false)
|
||||
}
|
||||
|
||||
async fn main(
|
||||
&self,
|
||||
config: &serde_yaml::Value,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
) -> Result<(), DependencyError> {
|
||||
let cfg: PythonConfig = serde_yaml::from_value(config.clone()).map_err(|e| {
|
||||
log::error!("Failed to parse user.python config: {}", e);
|
||||
DependencyError::ParseError()
|
||||
})?;
|
||||
|
||||
let engine = Engine::new();
|
||||
|
||||
for action in &cfg.0 {
|
||||
self.run_action(&engine, action, ctx, event_tx).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Python {
|
||||
async fn run_action(
|
||||
&self,
|
||||
engine: &Engine,
|
||||
action: &PythonAction,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
) -> Result<(), DependencyError> {
|
||||
let delta = env_delta(action);
|
||||
let idx = index_flag(action);
|
||||
|
||||
// ── 1. venv ──────────────────────────────────────────────
|
||||
if let Some(ref dir) = action.directory {
|
||||
let venv_cmd = format!("{} -m venv '{}'", python_bin(action), dir.display());
|
||||
log::info!("Python UPM creating venv: {}", venv_cmd);
|
||||
let r = engine
|
||||
.execute_command_with_delta(&venv_cmd, ctx, event_tx, &delta)
|
||||
.await;
|
||||
if let Err(e) = r {
|
||||
log::error!("Failed to create venv: {}", e);
|
||||
return Err(DependencyError::CustomFailed(e));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. backend command ──────────────────────────────────
|
||||
match action.type_.as_str() {
|
||||
"pip" => {
|
||||
if !action.packages.is_empty() {
|
||||
let cmd = format!(
|
||||
"{} install{}{} {}",
|
||||
venv_pip(action),
|
||||
idx,
|
||||
action.args.iter().fold(String::new(), |a, b| a + " " + b),
|
||||
action.packages.join(" "),
|
||||
);
|
||||
log::info!("Python UPM executing: {}", cmd);
|
||||
engine
|
||||
.execute_command_with_delta(&cmd, ctx, event_tx, &delta)
|
||||
.await
|
||||
.map_err(DependencyError::CustomFailed)?;
|
||||
}
|
||||
if let Some(ref manifest) = action.manifest {
|
||||
let cmd = format!(
|
||||
"{} install{} -r '{}'{}",
|
||||
venv_pip(action),
|
||||
idx,
|
||||
manifest.display(),
|
||||
action.args.iter().fold(String::new(), |a, b| a + " " + b),
|
||||
);
|
||||
log::info!("Python UPM executing: {}", cmd);
|
||||
engine
|
||||
.execute_command_with_delta(&cmd, ctx, event_tx, &delta)
|
||||
.await
|
||||
.map_err(DependencyError::CustomFailed)?;
|
||||
}
|
||||
}
|
||||
|
||||
"uv" => {
|
||||
// uv pip install <packages> / uv pip sync <manifest>
|
||||
if !action.packages.is_empty() {
|
||||
let cmd = format!(
|
||||
"uv pip install{}{} {}",
|
||||
idx,
|
||||
action.args.iter().fold(String::new(), |a, b| a + " " + b),
|
||||
action.packages.join(" "),
|
||||
);
|
||||
log::info!("Python UPM executing: {}", cmd);
|
||||
engine
|
||||
.execute_command_with_delta(&cmd, ctx, event_tx, &delta)
|
||||
.await
|
||||
.map_err(DependencyError::CustomFailed)?;
|
||||
}
|
||||
if let Some(ref manifest) = action.manifest {
|
||||
let cmd = format!(
|
||||
"uv pip sync '{}'{}",
|
||||
manifest.display(),
|
||||
action.args.iter().fold(String::new(), |a, b| a + " " + b),
|
||||
);
|
||||
log::info!("Python UPM executing: {}", cmd);
|
||||
engine
|
||||
.execute_command_with_delta(&cmd, ctx, event_tx, &delta)
|
||||
.await
|
||||
.map_err(DependencyError::CustomFailed)?;
|
||||
}
|
||||
}
|
||||
|
||||
"poetry" => {
|
||||
let cmd = format!(
|
||||
"poetry install{}",
|
||||
action.args.iter().fold(String::new(), |a, b| a + " " + b),
|
||||
);
|
||||
log::info!("Python UPM executing: {}", cmd);
|
||||
engine
|
||||
.execute_command_with_delta(&cmd, ctx, event_tx, &delta)
|
||||
.await
|
||||
.map_err(DependencyError::CustomFailed)?;
|
||||
}
|
||||
|
||||
"pipx" => {
|
||||
if !action.packages.is_empty() {
|
||||
let cmd = format!(
|
||||
"pipx install{}{} {}",
|
||||
idx,
|
||||
action.args.iter().fold(String::new(), |a, b| a + " " + b),
|
||||
action.packages.join(" "),
|
||||
);
|
||||
log::info!("Python UPM executing: {}", cmd);
|
||||
engine
|
||||
.execute_command_with_delta(&cmd, ctx, event_tx, &delta)
|
||||
.await
|
||||
.map_err(DependencyError::CustomFailed)?;
|
||||
}
|
||||
}
|
||||
|
||||
other => {
|
||||
log::error!("Unknown Python backend type: {}", other);
|
||||
return Err(DependencyError::ParseError());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_name() {
|
||||
assert_eq!(Python.name(), "python");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_dependency_basic() {
|
||||
let yaml = "- pip: {}";
|
||||
let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
|
||||
let deps = Python.system_dependency(&v);
|
||||
assert_eq!(deps.packages, vec!["python3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_dependency_uv() {
|
||||
let yaml = r#"
|
||||
- type: uv
|
||||
packages: [pytest]
|
||||
"#;
|
||||
let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
|
||||
let deps = Python.system_dependency(&v);
|
||||
assert!(deps.packages.contains(&"python3".to_string()));
|
||||
assert!(deps.packages.contains(&"uv".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pip_action() {
|
||||
let yaml = r#"
|
||||
- type: pip
|
||||
packages: [pytest, requests]
|
||||
"#;
|
||||
let cfg: PythonConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(cfg.0.len(), 1);
|
||||
let a = &cfg.0[0];
|
||||
assert_eq!(a.type_, "pip");
|
||||
assert_eq!(a.packages, vec!["pytest", "requests"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_full_action() {
|
||||
let yaml = r#"
|
||||
- type: uv
|
||||
python: "python3.14"
|
||||
directory: ".venv"
|
||||
index: "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
|
||||
packages: [pytest, requests]
|
||||
manifest: "requirements.txt"
|
||||
args: ["--upgrade"]
|
||||
env:
|
||||
UV_PYTHON: "python3.14"
|
||||
"#;
|
||||
let cfg: PythonConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
let a = &cfg.0[0];
|
||||
assert_eq!(a.type_, "uv");
|
||||
assert_eq!(a.python.as_deref(), Some("python3.14"));
|
||||
assert_eq!(a.directory, Some(PathBuf::from(".venv")));
|
||||
assert_eq!(
|
||||
a.index.as_deref(),
|
||||
Some("https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"),
|
||||
);
|
||||
assert_eq!(a.packages, vec!["pytest", "requests"]);
|
||||
assert_eq!(a.manifest, Some(PathBuf::from("requirements.txt")));
|
||||
assert_eq!(a.args, vec!["--upgrade"]);
|
||||
assert_eq!(
|
||||
a.env_.get("UV_PYTHON").and_then(|v| v.as_deref()),
|
||||
Some("python3.14"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_actions() {
|
||||
let yaml = r#"
|
||||
- type: pip
|
||||
packages: [pytest]
|
||||
- type: uv
|
||||
directory: ".venv"
|
||||
manifest: "requirements.txt"
|
||||
"#;
|
||||
let cfg: PythonConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(cfg.0.len(), 2);
|
||||
assert_eq!(cfg.0[0].type_, "pip");
|
||||
assert_eq!(cfg.0[1].type_, "uv");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
use crate::error::DependencyError;
|
||||
use crate::types::{DeltaExecutionContext, Engine, EventSender, ExecutionContext};
|
||||
use crate::upm::UPMSysDeps;
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
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<PathBuf> {
|
||||
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 {
|
||||
#[allow(dead_code)]
|
||||
/// UPM backend type (currently only `"rustup"`).
|
||||
#[serde(default = "default_rust_type", rename = "type")]
|
||||
type_: String,
|
||||
|
||||
/// Environment variables injected into every rustup/cargo child process.
|
||||
/// Each entry maps to a var name; `None` unsets the variable.
|
||||
#[serde(default, rename = "env")]
|
||||
env_: HashMap<String, Option<String>>,
|
||||
|
||||
/// Rust toolchain to activate via `rustup default`.
|
||||
/// If unset, the existing default toolchain is left alone.
|
||||
#[serde(default)]
|
||||
toolchain: Option<String>,
|
||||
|
||||
/// Rustup components to install (e.g. `clippy`, `rustfmt`).
|
||||
#[serde(default)]
|
||||
components: Vec<String>,
|
||||
|
||||
/// Compilation targets for cross-compilation.
|
||||
/// Each entry runs `rustup target add <target>`.
|
||||
#[serde(default)]
|
||||
targets: Vec<String>,
|
||||
|
||||
/// Cargo sub-configuration (fetch, install, manifests, …).
|
||||
/// Optional — omit if you only need rustup-level setup.
|
||||
#[serde(default)]
|
||||
cargo: CargoSubConfig,
|
||||
}
|
||||
|
||||
fn default_rust_type() -> String {
|
||||
"rustup".to_string()
|
||||
}
|
||||
|
||||
// ── 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<String>,
|
||||
|
||||
/// Alternative registry URL (passed as `--registry`).
|
||||
#[serde(default)]
|
||||
registry: Option<String>,
|
||||
|
||||
/// Additional flags forwarded to `cargo install`
|
||||
/// (e.g. `--force`, `--version 0.1.0`).
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
|
||||
/// One or more `Cargo.toml` paths for `cargo fetch`.
|
||||
/// Each path spawns a separate `cargo fetch --manifest-path <path>`.
|
||||
#[serde(default = "default_manifests")]
|
||||
manifests: Vec<PathBuf>,
|
||||
|
||||
/// 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<String> = Vec::new();
|
||||
|
||||
// ── 1. rustup default <toolchain> ─────────────────────────
|
||||
if let Some(ref tc) = cfg.toolchain {
|
||||
commands.push(format!("rustup default '{}'", tc));
|
||||
}
|
||||
|
||||
// ── 2. rustup component add <comp…> ──────────────────────
|
||||
if !cfg.components.is_empty() {
|
||||
commands.push(format!("rustup component add {}", cfg.components.join(" ")));
|
||||
}
|
||||
|
||||
// ── 3. rustup target add <target…> ───────────────────────
|
||||
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 delta = DeltaExecutionContext {
|
||||
env_vars: cfg.env_.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let result = engine
|
||||
.execute_command_with_delta(cmd, ctx, event_tx, &delta)
|
||||
.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")]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user