diff --git a/.gitignore b/.gitignore index 3e0ea9c..b2462bd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ archived *.bak *.clean session* -llm.env +.secret # System test artifacts tests/results/ diff --git a/runner.sh b/runner.sh deleted file mode 100755 index 438fd83..0000000 --- a/runner.sh +++ /dev/null @@ -1,217 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# HoneyBiscuitWorkshop System Test Runner -# -# Usage: -# ./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="$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 -} -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..." - cargo build -p workshop-baker --target "$target" 2>&1 | tail -n 1 - fi -} - -# ── staging ───────────────────────────────────────────────────────── - -prepare_staging() { - 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 -} - -# ── 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 -} - -# ── run ───────────────────────────────────────────────────────────── - -run_container() { - local filter="" - if [ "$TEST_DIR" = "tests/engine" ]; then - filter="$(pm_filter)" - fi - - 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 \ - ${DOCKER_ENV[@]} "${mounts[@]}" "$TEST_IMAGE" - else - # 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 -} - -# ── main ──────────────────────────────────────────────────────────── - -build_binary -prepare_staging -run_container -echo "[runner] Done." diff --git a/tests/Dockerfile b/tests/Dockerfile deleted file mode 100644 index 22357da..0000000 --- a/tests/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -FROM archlinux:latest - -RUN sed -i 's|https://geo.mirror.pkgbuild.com|https://mirrors.tuna.tsinghua.edu.cn/archlinux|g' /etc/pacman.d/mirrorlist && \ - sed -i 's|https://mirror.rackspace.com/archlinux|https://mirrors.tuna.tsinghua.edu.cn/archlinux|g' /etc/pacman.d/mirrorlist && \ - echo 'Server = https://mirrors.tuna.tsinghua.edu.cn/archlinux/$repo/os/$arch' >> /etc/pacman.d/mirrorlist - -RUN pacman -Syu --noconfirm && \ - pacman -S --noconfirm \ - python python-pip base-devel git curl wget sudo \ - which procps-ng shadow && \ - pacman -Scc --noconfirm - -# Create test user -RUN useradd -m -s /bin/bash testuser - -# Python environment -WORKDIR /app -COPY tests/requirements.txt ./requirements.txt -RUN python -m venv /opt/test-venv && \ - /opt/test-venv/bin/pip install -r requirements.txt - -# Copy test suite -COPY tests/ ./tests/ -COPY tests/entrypoint.sh ./entrypoint.sh -RUN chmod +x entrypoint.sh - -# Environment -ENV PATH="/opt/test-venv/bin:$PATH" -ENV PYTHONUNBUFFERED=1 -ENV PYTHONDONTWRITEBYTECODE=1 - -ENTRYPOINT ["./entrypoint.sh"] diff --git a/tests/baker/test_bake.py b/tests/baker/test_bake.py deleted file mode 100644 index 6afb43c..0000000 --- a/tests/baker/test_bake.py +++ /dev/null @@ -1,105 +0,0 @@ -import os -import pytest - -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(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"], f"Bake failed: {result['stderr']}" - assert "Hello from simple bake script" in result["stdout"] - - def test_simple_script_sets_env_vars(self, run_baker, bake_workspace, work_dir): - prebake_cfg = os.path.join( - 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") - - result = run_baker( - "bake", script, - "--bake-base", bake_base, - "--prebake", prebake_cfg, - cwd=work_dir, - timeout=60, - ) - 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(os.path.dirname(__file__)), "fixtures", "prebake", "minimal.yml" - ) - script = os.path.join(FIXTURES, "pipeline.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"], f"Pipeline bake failed: {result['stderr']}" - assert os.path.exists("/tmp/bake-test-output/step1.txt") - assert os.path.exists("/tmp/bake-test-output/step2.txt") - - def test_pipeline_step_outputs_correct(self, run_baker, bake_workspace, work_dir): - prebake_cfg = os.path.join( - 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") - - result = run_baker( - "bake", script, - "--bake-base", bake_base, - "--prebake", prebake_cfg, - cwd=work_dir, - timeout=60, - ) - assert result["success"] - with open("/tmp/bake-test-output/step1.txt") as f: - assert f.read().strip() == "step1-done" - 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(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") - - result = run_baker( - "bake", script, - "--bake-base", bake_base, - "--prebake", prebake_cfg, - cwd=work_dir, - timeout=60, - ) - assert result["success"], f"@after bake failed: {result['stderr']}" - assert os.path.exists("/tmp/bake-after-test/status.txt") - assert os.path.exists("/tmp/bake-after-test/source.txt") - assert os.path.exists("/tmp/bake-after-test/build.txt") - diff --git a/tests/baker/test_finalize.py b/tests/baker/test_finalize.py deleted file mode 100644 index 8626af4..0000000 --- a/tests/baker/test_finalize.py +++ /dev/null @@ -1,62 +0,0 @@ -import os -import pytest - -from probes import file -from llm import judge_log, LLMClient - -FIXTURES = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures", "finalize") - -@pytest.mark.finalize -class TestFinalizeMinimal: - - def test_minimal_finalize_succeeds(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"], f"Minimal finalize failed: {result['stderr']}" - -@pytest.mark.finalize -class TestFinalizeHooks: - - def test_early_hook_executes(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "with_hooks.yml") - result = run_baker("finalize", config, cwd=work_dir, timeout=60) - assert result["success"] - assert "finalize early hook" in result["stdout"] or "finalize early hook" in result["stderr"] - - def test_late_hook_executes(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "with_hooks.yml") - result = run_baker("finalize", config, cwd=work_dir, timeout=60) - assert result["success"] - assert "finalize late hook" in result["stdout"] or "finalize late hook" in result["stderr"] - -@pytest.mark.finalize -@pytest.mark.llm -class TestFinalizeWithLLM: - - def test_finalize_hooks_via_llm(self, run_baker, llm_env, work_dir): - if not llm_env: - pytest.skip("LLM environment not configured") - - config = os.path.join(FIXTURES, "with_hooks.yml") - result = run_baker("finalize", config, cwd=work_dir, timeout=60) - - combined_log = result["stdout"] + "\n" + result["stderr"] - - judgment = judge_log( - operation="finalize with early and late hooks", - expected_behavior="Both early and late hooks executed successfully, " - "printing 'finalize early hook' and 'finalize late hook'", - actual_log=combined_log, - ) - assert judgment.passed, f"LLM judgment failed: {judgment.reason}" - -@pytest.mark.finalize -class TestFinalizePlugins: - - @pytest.mark.skip(reason="Plugin download test not yet implemented") - def test_plugin_download(self, run_baker, work_dir): - pass - - @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 deleted file mode 100644 index a513ace..0000000 --- a/tests/baker/test_prebake.py +++ /dev/null @@ -1,202 +0,0 @@ -import os -import json -import pytest - -from probes import user, file, package - -FIXTURES = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures", "prebake") - -@pytest.mark.prebake -class TestPrebakeConfigValidation: - - def test_invalid_version_fails(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "invalid_version.yml") - result = run_baker("prebake", config, cwd=work_dir, timeout=60) - assert not result["success"], f"Expected prebake to fail with invalid version" - assert "version" in result["stderr"].lower() or "Version" in result["stderr"] - - def test_missing_required_field_fails(self, run_baker, work_dir): - missing_env = os.path.join(work_dir, "missing_env.yml") - with open(missing_env, "w") as f: - f.write('version: "1.0"\n') - result = run_baker("prebake", missing_env, cwd=work_dir, timeout=60) - assert not result["success"] - - def test_minimal_config_succeeds(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "minimal.yml") - result = run_baker("prebake", config, cwd=work_dir, timeout=60) - assert result["success"], f"Minimal prebake failed: {result['stderr']}" - -@pytest.mark.prebake -class TestPrebakeBootstrap: - - def test_bootstrap_creates_vulcan_user(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"], f"prebake failed: {result['stderr']}" - assert user.user_exists("vulcan") - - def test_bootstrap_creates_workspace(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 file.dir_exists("/home/vulcan/workspace") - - def test_bootstrap_creates_symlink(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.islink("/workspace") - - def test_bootstrap_generates_bootstrap_script(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 file.file_exists("/bootstrap.sh") - - def test_bootstrap_script_permissions(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"] - mode = file.file_permissions("/bootstrap.sh") - assert mode & 0o111, f"/bootstrap.sh not executable, mode: {oct(mode)}" - - def test_bootstrap_script_contains_user_creation(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 file.file_exists("/bootstrap.sh") - assert file.file_contains("/bootstrap.sh", "useradd") - - def test_bootstrap_script_contains_workspace_setup(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 file.file_contains("/bootstrap.sh", "mkdir -p /home/vulcan/workspace") - - def test_bootstrap_with_pacman_creates_sudoers(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 file.file_exists("/etc/sudoers.d/workshop") - - def test_custom_bootstrap_executes(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "with_custom_bootstrap.yml") - result = run_baker("prebake", config, cwd=work_dir, timeout=60) - assert result["success"], f"Custom bootstrap failed: {result['stderr']}" - assert file.dir_exists("/custom-workspace") - -@pytest.mark.prebake -class TestPrebakeDryRun: - - def test_dry_run_does_not_create_user(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "bootstrap_only.yml") - env = {"HBW_DRY_RUN": "true"} - result = run_baker("prebake", config, cwd=work_dir, env=env, timeout=60) - assert result["success"], f"Dry-run failed: {result['stderr']}" - - def test_dry_run_logs_script_content(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "bootstrap_only.yml") - env = {"HBW_DRY_RUN": "true", "RUST_LOG": "info"} - result = run_baker("prebake", config, cwd=work_dir, env=env, timeout=60) - assert result["success"] - combined = result["stdout"] + result["stderr"] - assert "DRY_RUN" in combined or "dry_run" in combined.lower() - -@pytest.mark.prebake -class TestPrebakeHooks: - - #@pytest.mark.xfail(reason="Hook execution fails with Permission denied in container environment", strict=False) - def test_early_hook_executes(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "with_hooks.yml") - result = run_baker("prebake", config, cwd=work_dir, timeout=60) - assert result["success"], f"prebake with hooks failed: {result['stderr']}" - combined = result["stdout"] + result["stderr"] - assert "early hook executed" in combined - - #@pytest.mark.xfail(reason="Hook execution fails with Permission denied in container environment", strict=False) - def test_late_hook_executes(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "with_hooks.yml") - result = run_baker("prebake", config, cwd=work_dir, timeout=60) - assert result["success"], f"prebake with hooks failed: {result['stderr']}" - combined = result["stdout"] + result["stderr"] - assert "late hook executed" in combined - - #@pytest.mark.xfail(reason="Hook execution fails with Permission denied in container environment", strict=False) - def test_hooks_run_in_order(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "with_hooks.yml") - result = run_baker("prebake", config, cwd=work_dir, timeout=60) - assert result["success"] - combined = result["stdout"] + result["stderr"] - early_pos = combined.find("early hook executed") - late_pos = combined.find("late hook executed") - assert early_pos != -1 and late_pos != -1 - assert early_pos < late_pos, "Early hook should execute before late hook" - - def test_empty_hooks_succeed(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "minimal.yml") - 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: - - 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.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") - with open(mirror_config, "w") as f: - f.write(''' -version: "1.0" -environment: - builder: "baremetal" -bootstrap: - user: "vulcan" -dependencies: - system: - pacman: - packages: ["which"] - mirror: "https://mirrors.tuna.tsinghua.edu.cn/archlinux/$repo/os/$arch" -''') - 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: - - def test_prebake_envvars_injected(self, run_baker, work_dir): - hook_config = os.path.join(work_dir, "envvar_hook.yml") - with open(hook_config, "w") as f: - f.write(''' -version: "1.0" -environment: - builder: "baremetal" -bootstrap: - user: "vulcan" -envvars: - prebake: - TEST_INJECTED: "injected_value" -hooks: - early: - - name: "check-env" - command: "echo $TEST_INJECTED" -''') - result = run_baker("prebake", hook_config, cwd=work_dir, timeout=60) - assert result["success"], f"prebake with envvars failed: {result['stderr']}" - combined = result["stdout"] + result["stderr"] - assert "injected_value" in combined - -@pytest.mark.prebake -class TestPrebakeSecurity: - - #@pytest.mark.xfail(reason="Privilege drop may fail in container without proper setup", strict=False) - def test_privilege_drop_after_bootstrap(self, run_baker, work_dir): - config = os.path.join(FIXTURES, "with_security.yml") - result = run_baker("prebake", config, cwd=work_dir, timeout=60) - assert result["success"], f"prebake with security failed: {result['stderr']}" - diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 4571301..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,180 +0,0 @@ -""" -Root conftest for HoneyBiscuitWorkshop system tests. - -Provides core fixtures for: -- Locating the baker binary and examples -- Running subprocess commands with output capture -- Managing temporary working directories -""" -import os -import subprocess -import shutil -import tempfile -import pytest - - -def pytest_configure(config): - """Register custom markers.""" - config.addinivalue_line("markers", "prebake: prebake stage tests") - config.addinivalue_line("markers", "bake: bake stage tests") - config.addinivalue_line("markers", "finalize: finalize stage tests") - config.addinivalue_line("markers", "llm: tests requiring LLM API") - config.addinivalue_line("markers", "slow: slow running tests") - - -@pytest.fixture(scope="session") -def baker_bin(): - """Path to the workshop-baker binary.""" - path = os.environ.get("BAKER_BIN", "/workshop/workshop-baker") - if not os.path.isfile(path): - pytest.skip(f"Baker binary not found at {path}") - if not os.access(path, os.X_OK): - pytest.skip(f"Baker binary not executable at {path}") - return path - - -@pytest.fixture(scope="session") -def examples_dir(): - """Path to the examples directory (may be None).""" - path = os.environ.get("EXAMPLES_DIR", "/workshop/examples") - if not os.path.isdir(path): - return None - return path - - -@pytest.fixture(scope="session") -def llm_env(): - """LLM environment variables dict (from llm.env).""" - env = {} - for var in [ - "OPENAI_BASE_URL", - "OPENAI_API_KEY", - "ANTHROPIC_BASE_URL", - "ANTHROPIC_API_KEY", - "MODEL", - "REASONING_EFFORT", - ]: - val = os.environ.get(var) - if val: - env[var] = val - return env - - -@pytest.fixture -def work_dir(): - """Create a temporary working directory for a test, cleaned up after.""" - d = tempfile.mkdtemp(prefix="hbw-test-") - yield d - shutil.rmtree(d, ignore_errors=True) - - -@pytest.fixture -def run_cmd(): - """Fixture that provides a command runner function. - - Returns a function that runs a command and returns - (stdout, stderr, returncode, success). - """ - - def _run(args, cwd=None, env=None, timeout=120): - full_env = {**os.environ} - if env: - full_env.update(env) - - show_logs = os.environ.get("BAKER_LOG", "0") == "1" - - try: - 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": "", - "stderr": f"Command timed out after {timeout}s", - "returncode": -1, - "success": False, - } - except Exception as e: - return { - "stdout": "", - "stderr": str(e), - "returncode": -1, - "success": False, - } - - return _run - - -@pytest.fixture -def run_baker(baker_bin, run_cmd): - """Fixture that provides a baker command runner. - - Returns a function that runs workshop-baker with common flags. - """ - - def _run(subcommand, *args, cwd=None, env=None, timeout=120): - cmd = [ - baker_bin, - "-u", "testuser", - "-p", "test-pipeline", - "-b", "test-build-001", - "bare", - subcommand, - ] - cmd.extend(args) - return run_cmd(cmd, cwd=cwd, env=env, timeout=timeout) - - return _run - - -@pytest.fixture -def fixtures_dir(): - """Path to the test fixtures directory.""" - return os.path.join(os.path.dirname(__file__), "fixtures") - - -@pytest.fixture -def bake_workspace(): - """Create /workspace for bake tests (baker writes temp scripts there).""" - existed = os.path.exists("/workspace") - if not existed: - os.makedirs("/workspace", exist_ok=True) - yield - if not existed: - shutil.rmtree("/workspace", ignore_errors=True) diff --git a/tests/engine/test_pm.py b/tests/engine/test_pm.py deleted file mode 100644 index c8ae541..0000000 --- a/tests/engine/test_pm.py +++ /dev/null @@ -1,102 +0,0 @@ -"""PM system tests driven by bare workshop-baker prebake.""" - -import os -import pytest -from probes import binary_exists - -PACMAN_MIRROR = "https://mirrors.tuna.tsinghua.edu.cn/archlinux/$repo/os/$arch" - - -def _yml(pm, pkgs, mirror="", repo=None): - """Build prebake.yml. repo is a list of lines (without lead indent).""" - m = "" - if mirror: - m += ' mirror: "' + mirror + '"\n' - if repo: - m += " repositories:\n" - for line in repo: - m += " " + line + "\n" - return """\ -version: "1.0" -environment: - builder: "baremetal" -bootstrap: - user: "vulcan" -dependencies: - system: - {pm}: - packages: [{pkgs}] -{m}""".format(pm=pm, pkgs=pkgs, m=m) - - -# ── install ────────────────────────────────────────────────────────── - -@pytest.mark.parametrize("pm_name,pkgs", [ - ("pacman", '"curl", "git"'), - ("apt", '"curl", "git"'), - ("dnf", '"curl", "git"'), - ("apk", '"curl", "git"'), -]) -def test_install(run_cmd, work_dir, pm_name, pkgs): - cfg = os.path.join(work_dir, "pm.yml") - mirror = PACMAN_MIRROR if pm_name == "pacman" else "" - with open(cfg, "w") as f: - f.write(_yml(pm_name, pkgs, mirror=mirror)) - r = run_cmd(["/baker", "-p", "t", "-b", "1", "-u", "testuser", - "bare", "prebake", cfg], timeout=120) - assert r["success"], "[{}] prebake: {}".format(pm_name, r["stderr"][:300]) - for pkg in ["curl", "git"]: - assert binary_exists("/usr/bin/" + pkg), "[{}] {} missing".format(pm_name, pkg) - - -@pytest.mark.parametrize("pm_name", ["pacman", "apt", "dnf", "apk"]) -def test_reinstall(run_cmd, work_dir, pm_name): - cfg = os.path.join(work_dir, "pm.yml") - mirror = PACMAN_MIRROR if pm_name == "pacman" else "" - with open(cfg, "w") as f: - f.write(_yml(pm_name, '"curl"', mirror=mirror)) - cmd = ["/baker", "-p", "t", "-b", "1", "-u", "testuser", "bare", "prebake", cfg] - r1 = run_cmd(cmd, timeout=120) - assert r1["success"], "[{}] first install failed".format(pm_name) - r2 = run_cmd(cmd, timeout=120) - assert r2["success"], "[{}] reinstall failed".format(pm_name) - - -# ── change_mirror ──────────────────────────────────────────────────── - -@pytest.mark.parametrize("pm_name", ["pacman", "apt", "dnf", "apk"]) -def test_mirror(run_cmd, work_dir, pm_name): - cfg = os.path.join(work_dir, "pm.yml") - mirror = PACMAN_MIRROR if pm_name == "pacman" else "" - with open(cfg, "w") as f: - f.write(_yml(pm_name, '"which"', mirror=mirror)) - r = run_cmd(["/baker", "-p", "t", "-b", "1", "-u", "testuser", - "bare", "prebake", cfg], timeout=120) - assert r["success"], "[{}] mirror: {}".format(pm_name, r["stderr"][:300]) - assert binary_exists("/usr/bin/which"), "[{}] which not installed".format(pm_name) - - -# ── add_repository ─────────────────────────────────────────────────── - -@pytest.mark.parametrize("pm_name,repo_lines,result_file", [ - ("pacman", ['- name: custom', ' server: "https://repo.example.com/$arch"'], - "/etc/pacman.conf"), - ("apt", ['- url: "https://repo.example.com/debian"', ' distro: bookworm'], - "/etc/apt/sources.list.d/custom.list"), - ("dnf", ['- name: custom', ' baseurl: "https://repo.example.com/fedora"'], - "/etc/yum.repos.d/custom.repo"), - ("apk", ['- url: "https://repo.example.com/v3.19/main"'], - "/etc/apk/repositories"), -]) -@pytest.mark.xfail(reason="fake repo URL, package install may fail", strict=False) -def test_add_repo(run_cmd, work_dir, pm_name, repo_lines, result_file): - cfg = os.path.join(work_dir, "pm.yml") - mirror = PACMAN_MIRROR if pm_name == "pacman" else "" - with open(cfg, "w") as f: - f.write(_yml(pm_name, '"curl"', mirror=mirror, repo=repo_lines)) - r = run_cmd(["/baker", "-p", "t", "-b", "1", "-u", "testuser", - "bare", "prebake", cfg], timeout=120) - # Prebake may fail if repo URL is unreachable — check only the config file - # was written by add_repository before the (possibly failing) install step. - assert binary_exists(result_file), \ - "[{}] repo file missing: {}".format(pm_name, result_file) diff --git a/tests/entrypoint.sh b/tests/entrypoint.sh deleted file mode 100755 index 42563b4..0000000 --- a/tests/entrypoint.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash -set -e - -# Load LLM environment if available -if [ -f /workshop/llm.env ]; then - set -a - source /workshop/llm.env - set +a -fi - -# Export test configuration -export BAKER_BIN=/workshop/workshop-baker -export EXAMPLES_DIR=/workshop/examples -export TEST_MODE=${TEST_MODE:-auto} - -if [ "$TEST_MODE" = "manual" ]; then - echo "========================================" - echo " HoneyBiscuitWorkshop System Tests" - echo " Mode: MANUAL (interactive shell)" - echo "========================================" - echo "" - echo "Binary: $BAKER_BIN" - echo "Examples: $EXAMPLES_DIR" - echo "" - echo "Run tests manually:" - echo " cd /app && pytest tests/ -v" - echo "" - exec /bin/bash -else - echo "========================================" - echo " HoneyBiscuitWorkshop System Tests" - echo " Mode: AUTO (running pytest)" - echo "========================================" - cd /app - exec python -m pytest tests/ -v --tb=short --junitxml=/results/report.xml "$@" -fi diff --git a/tests/fixtures/bake/bake_base.sh b/tests/fixtures/bake/bake_base.sh deleted file mode 100644 index 7108dfa..0000000 --- a/tests/fixtures/bake/bake_base.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -[ -f /dev/shm/bakeexport.{{ name }} ] && . /dev/shm/bakeexport.{{ name }} || true -{{ condition }} -{{ main }} -{{ name }} -{{ export }} diff --git a/tests/fixtures/bake/pipeline.sh b/tests/fixtures/bake/pipeline.sh deleted file mode 100644 index 8ffc060..0000000 --- a/tests/fixtures/bake/pipeline.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -# @pipeline -step_one() { - echo "Step 1: Setup" - mkdir -p /tmp/bake-test-output - echo "step1-done" > /tmp/bake-test-output/step1.txt -} - -# @pipeline -step_two() { - echo "Step 2: Build" - echo "step2-done" > /tmp/bake-test-output/step2.txt -} - -# @pipeline -step_three() { - echo "Step 3: Verify" - if [ -f /tmp/bake-test-output/step1.txt ] && [ -f /tmp/bake-test-output/step2.txt ]; then - echo "All steps completed successfully" - else - echo "ERROR: Missing step outputs" - exit 1 - fi -} diff --git a/tests/fixtures/bake/simple.sh b/tests/fixtures/bake/simple.sh deleted file mode 100644 index 3f51aa4..0000000 --- a/tests/fixtures/bake/simple.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -echo "Hello from simple bake script" -echo "Task ID: $HBW_TASKID" -echo "Pipeline: $HBW_PIPELINE" -exit 0 diff --git a/tests/fixtures/bake/with_after.sh b/tests/fixtures/bake/with_after.sh deleted file mode 100644 index b7c70af..0000000 --- a/tests/fixtures/bake/with_after.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash - -# @pipeline -init_workspace() { - echo "Initializing workspace" - mkdir -p /tmp/bake-after-test - echo "init" > /tmp/bake-after-test/status.txt -} - -# @pipeline -# @after(init_workspace) -fetch_source() { - echo "Fetching source (depends on init_workspace)" - echo "fetched" > /tmp/bake-after-test/source.txt -} - -# @pipeline -# @after(fetch_source) -build_project() { - echo "Building project (depends on fetch_source)" - echo "built" > /tmp/bake-after-test/build.txt -} diff --git a/tests/fixtures/finalize/hook_early.sh b/tests/fixtures/finalize/hook_early.sh deleted file mode 100644 index 02d15c5..0000000 --- a/tests/fixtures/finalize/hook_early.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -echo "EARLY_HOOK_EXECUTED" -echo "Working directory: $(pwd)" -echo "User: $(whoami)" diff --git a/tests/fixtures/finalize/hook_late.sh b/tests/fixtures/finalize/hook_late.sh deleted file mode 100644 index a13290d..0000000 --- a/tests/fixtures/finalize/hook_late.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -echo "LATE_HOOK_EXECUTED" -echo "Working directory: $(pwd)" diff --git a/tests/fixtures/finalize/minimal.yml b/tests/fixtures/finalize/minimal.yml deleted file mode 100644 index e35f838..0000000 --- a/tests/fixtures/finalize/minimal.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: "0.0.1" -plugin: {} -notification: {} -artifact: [] -cleanup: - policy: "auto" diff --git a/tests/fixtures/finalize/with_hooks.yml b/tests/fixtures/finalize/with_hooks.yml deleted file mode 100644 index 8cace4d..0000000 --- a/tests/fixtures/finalize/with_hooks.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: "0.0.1" -plugin: {} -notification: {} -artifact: [] -cleanup: - policy: "auto" -hooks: - early: - - name: "finalize-early" - command: "echo 'finalize early hook'" - late: - - name: "finalize-late" - command: "echo 'finalize late hook'" diff --git a/tests/fixtures/pm/Dockerfile.apk b/tests/fixtures/pm/Dockerfile.apk deleted file mode 100644 index 4316071..0000000 --- a/tests/fixtures/pm/Dockerfile.apk +++ /dev/null @@ -1,6 +0,0 @@ -FROM docker.1ms.run/library/alpine:3.19 -RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apk/repositories -RUN apk add --no-cache python3 py3-pip bash -COPY workshop-baker/target/x86_64-unknown-linux-musl/debug/workshop-baker /baker -RUN chmod +x /baker -ENTRYPOINT ["/bin/sh", "-c"] diff --git a/tests/fixtures/pm/Dockerfile.apt b/tests/fixtures/pm/Dockerfile.apt deleted file mode 100644 index ebd6ef5..0000000 --- a/tests/fixtures/pm/Dockerfile.apt +++ /dev/null @@ -1,6 +0,0 @@ -FROM docker.1ms.run/library/debian:bookworm-slim -RUN sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null; sed -i 's|security.debian.org|mirrors.tuna.tsinghua.edu.cn/debian-security|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null; true -RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends python3 python3-venv ca-certificates 2>&1 | tail -n 2 -COPY workshop-baker/target/x86_64-unknown-linux-musl/debug/workshop-baker /baker -RUN chmod +x /baker -ENTRYPOINT ["/bin/sh", "-c"] diff --git a/tests/fixtures/pm/Dockerfile.dnf b/tests/fixtures/pm/Dockerfile.dnf deleted file mode 100644 index 91cd387..0000000 --- a/tests/fixtures/pm/Dockerfile.dnf +++ /dev/null @@ -1,6 +0,0 @@ -FROM docker.1ms.run/library/fedora:43 -RUN sed -e 's|^metalink=|#metalink=|g' -e 's|^#baseurl=http://download.example/pub/fedora/linux|baseurl=https://mirrors.tuna.tsinghua.edu.cn/fedora|g' -i.bak /etc/yum.repos.d/fedora.repo /etc/yum.repos.d/fedora-updates.repo -RUN dnf install -y python3 2>&1 | tail -n 2 -COPY workshop-baker/target/x86_64-unknown-linux-musl/debug/workshop-baker /baker -RUN chmod +x /baker -ENTRYPOINT ["/bin/sh", "-c"] diff --git a/tests/fixtures/pm/Dockerfile.pacman b/tests/fixtures/pm/Dockerfile.pacman deleted file mode 100644 index 83eccbc..0000000 --- a/tests/fixtures/pm/Dockerfile.pacman +++ /dev/null @@ -1,2 +0,0 @@ -FROM docker.1ms.run/library/archlinux:latest -RUN echo 'Server = https://mirrors.tuna.tsinghua.edu.cn/archlinux/$repo/os/$arch' > /etc/pacman.d/mirrorlist diff --git a/tests/fixtures/prebake/bootstrap_only.yml b/tests/fixtures/prebake/bootstrap_only.yml deleted file mode 100644 index 34a1b43..0000000 --- a/tests/fixtures/prebake/bootstrap_only.yml +++ /dev/null @@ -1,8 +0,0 @@ -version: "1.0" -environment: - builder: "baremetal" -bootstrap: - user: "vulcan" - workspace: - path: "/home/vulcan/workspace" - fallback: true diff --git a/tests/fixtures/prebake/dry_run.yml b/tests/fixtures/prebake/dry_run.yml deleted file mode 100644 index 3e105da..0000000 --- a/tests/fixtures/prebake/dry_run.yml +++ /dev/null @@ -1,15 +0,0 @@ -version: "1.0" -environment: - builder: "baremetal" -bootstrap: - user: "vulcan" - workspace: - path: "/home/vulcan/workspace" - fallback: true -hooks: - early: - - name: "test-early-hook" - command: "echo 'early hook executed'" - late: - - name: "test-late-hook" - command: "echo 'late hook executed'" diff --git a/tests/fixtures/prebake/invalid_version.yml b/tests/fixtures/prebake/invalid_version.yml deleted file mode 100644 index f0e8581..0000000 --- a/tests/fixtures/prebake/invalid_version.yml +++ /dev/null @@ -1,3 +0,0 @@ -version: "0.5" -environment: - builder: "baremetal" diff --git a/tests/fixtures/prebake/minimal.yml b/tests/fixtures/prebake/minimal.yml deleted file mode 100644 index 34a1b43..0000000 --- a/tests/fixtures/prebake/minimal.yml +++ /dev/null @@ -1,8 +0,0 @@ -version: "1.0" -environment: - builder: "baremetal" -bootstrap: - user: "vulcan" - workspace: - path: "/home/vulcan/workspace" - fallback: true diff --git a/tests/fixtures/prebake/with_custom_bootstrap.yml b/tests/fixtures/prebake/with_custom_bootstrap.yml deleted file mode 100644 index fa0f291..0000000 --- a/tests/fixtures/prebake/with_custom_bootstrap.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: "1.0" -environment: - builder: "baremetal" -bootstrap: - user: "vulcan" - workspace: - path: "/home/vulcan/workspace" - fallback: true - custom: | - #!/bin/sh - echo 'custom bootstrap executed' - mkdir -p /custom-workspace diff --git a/tests/fixtures/prebake/with_depsuser.yml b/tests/fixtures/prebake/with_depsuser.yml deleted file mode 100644 index d3b1980..0000000 --- a/tests/fixtures/prebake/with_depsuser.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: "1.0" -environment: - builder: "baremetal" -bootstrap: - user: "vulcan" - workspace: - path: "/home/vulcan/workspace" - fallback: true -dependencies: - system: - pacman: - packages: - - "which" - user: - cargo: - - "ripgrep" diff --git a/tests/fixtures/prebake/with_envvars.yml b/tests/fixtures/prebake/with_envvars.yml deleted file mode 100644 index b5a041e..0000000 --- a/tests/fixtures/prebake/with_envvars.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: "1.0" -environment: - builder: "baremetal" -bootstrap: - user: "vulcan" - workspace: - path: "/home/vulcan/workspace" - fallback: true -envvars: - prebake: - TEST_PREBAKE_VAR: "prebake_value" - bake: - TEST_BAKE_VAR: "bake_value" diff --git a/tests/fixtures/prebake/with_hooks.yml b/tests/fixtures/prebake/with_hooks.yml deleted file mode 100644 index 4189790..0000000 --- a/tests/fixtures/prebake/with_hooks.yml +++ /dev/null @@ -1,17 +0,0 @@ -version: "1.0" -environment: - builder: "baremetal" -bootstrap: - user: "vulcan" - workspace: - path: "/home/vulcan/workspace" - fallback: true -hooks: - early: - - name: "test-early-hook" - command: "echo 'early hook executed'" - working_dir: "/workspace" - late: - - name: "test-late-hook" - command: "echo 'late hook executed'" - working_dir: "/workspace" diff --git a/tests/fixtures/prebake/with_pacman.yml b/tests/fixtures/prebake/with_pacman.yml deleted file mode 100644 index 04f5116..0000000 --- a/tests/fixtures/prebake/with_pacman.yml +++ /dev/null @@ -1,15 +0,0 @@ -version: "1.0" -environment: - builder: "baremetal" -bootstrap: - user: "vulcan" - workspace: - path: "/home/vulcan/workspace" - fallback: true -dependencies: - system: - pacman: - mirror: "https://mirrors.tuna.tsinghua.edu.cn/archlinux/$repo/os/$arch" - packages: - - "curl" - - "wget" diff --git a/tests/fixtures/prebake/with_security.yml b/tests/fixtures/prebake/with_security.yml deleted file mode 100644 index 9aa02f4..0000000 --- a/tests/fixtures/prebake/with_security.yml +++ /dev/null @@ -1,10 +0,0 @@ -version: "1.0" -environment: - builder: "baremetal" -bootstrap: - user: "vulcan" - workspace: - path: "/home/vulcan/workspace" - fallback: true -security: - drop_after: "Bootstrap" diff --git a/tests/llm/__init__.py b/tests/llm/__init__.py deleted file mode 100644 index 15f1768..0000000 --- a/tests/llm/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -LLM-based log judgment system for system tests. - -Uses LLM APIs to evaluate non-deterministic test outputs (logs, stderr, etc.) -where simple string matching is insufficient. -""" - -from .client import LLMClient -from .cache import LLMCache -from .judge import judge_log, JudgmentResult - -__all__ = ["LLMClient", "LLMCache", "judge_log", "JudgmentResult"] diff --git a/tests/llm/cache.py b/tests/llm/cache.py deleted file mode 100644 index a0767b7..0000000 --- a/tests/llm/cache.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Disk-based cache for LLM API responses. - -Uses SHA256 of (prompt + model + system_prompt) as cache key. -Cache files stored as JSON in ``.cache/`` directory. -""" - -import hashlib -import json -from pathlib import Path - - -class LLMCache: - """SHA256-based disk cache for LLM API responses.""" - - def __init__(self, cache_dir: str | None = None): - if cache_dir is None: - cache_dir = "/tmp/llm-cache" - self.cache_dir = Path(cache_dir) - self.cache_dir.mkdir(parents=True, exist_ok=True) - - def _make_key(self, prompt: str, model: str, system_prompt: str = "") -> str: - """Create deterministic cache key from request parameters.""" - data = json.dumps( - {"prompt": prompt, "model": model, "system": system_prompt}, - sort_keys=True, - ) - return hashlib.sha256(data.encode()).hexdigest() - - def get(self, prompt: str, model: str, system_prompt: str = "") -> dict | None: - """Retrieve cached response if exists.""" - key = self._make_key(prompt, model, system_prompt) - path = self.cache_dir / f"{key}.json" - if path.exists(): - try: - with open(path) as f: - return json.load(f) - except (json.JSONDecodeError, IOError): - return None - return None - - def set( - self, prompt: str, model: str, system_prompt: str, response: dict - ) -> None: - """Store response in cache.""" - key = self._make_key(prompt, model, system_prompt) - path = self.cache_dir / f"{key}.json" - with open(path, "w") as f: - json.dump(response, f, ensure_ascii=False) - - def clear(self) -> None: - """Clear all cached responses.""" - for f in self.cache_dir.glob("*.json"): - f.unlink() diff --git a/tests/llm/client.py b/tests/llm/client.py deleted file mode 100644 index 72400fa..0000000 --- a/tests/llm/client.py +++ /dev/null @@ -1,187 +0,0 @@ -""" -LLM client supporting both Anthropic and OpenAI APIs. - -Prefers Anthropic format (per project convention). -Falls back to OpenAI format if Anthropic is unavailable. -""" - -import os -import logging -from .cache import LLMCache - -logger = logging.getLogger(__name__) - - -class LLMClient: - """LLM client with caching support. Prefers Anthropic API.""" - - def __init__(self, cache: LLMCache | None = None): - self.cache = cache or LLMCache() - self._anthropic_client = None - self._openai_client = None - self._init_clients() - - def _init_clients(self): - """Initialize API clients based on available environment variables.""" - # Anthropic (preferred) - anthropic_key = os.environ.get("ANTHROPIC_API_KEY") - anthropic_base = os.environ.get("ANTHROPIC_BASE_URL") - if anthropic_key: - try: - import anthropic - - kwargs: dict = {"api_key": anthropic_key} - if anthropic_base: - kwargs["base_url"] = anthropic_base - self._anthropic_client = anthropic.Anthropic(**kwargs) - logger.info("Anthropic client initialized") - except ImportError: - logger.warning("anthropic package not installed") - - # OpenAI (fallback) - openai_key = os.environ.get("OPENAI_API_KEY") - openai_base = os.environ.get("OPENAI_BASE_URL") - if openai_key: - try: - import openai - - kwargs = {"api_key": openai_key} - if openai_base: - kwargs["base_url"] = openai_base - self._openai_client = openai.OpenAI(**kwargs) - logger.info("OpenAI client initialized") - except ImportError: - logger.warning("openai package not installed") - - @property - def available(self) -> bool: - """Check if any LLM client is available.""" - return ( - self._anthropic_client is not None or self._openai_client is not None - ) - - def complete( - self, prompt: str, system_prompt: str = "", max_tokens: int = 2000 - ) -> str: - """Send a completion request, with caching. - - Args: - prompt: User message. - system_prompt: System message (sets role / persona). - max_tokens: Max response tokens. - - Returns: - LLM response text. - - Raises: - RuntimeError: If no client is available. - """ - model = os.environ.get("MODEL", "deepseek-v4-flash") - - # Check cache - cached = self.cache.get(prompt, model, system_prompt) - if cached: - logger.info("Cache hit for LLM request") - return cached["content"] - - # Try Anthropic first - if self._anthropic_client: - content = self._call_anthropic( - prompt, system_prompt, model, max_tokens - ) - elif self._openai_client: - content = self._call_openai(prompt, system_prompt, model, max_tokens) - else: - raise RuntimeError( - "No LLM client available. " - "Set ANTHROPIC_API_KEY or OPENAI_API_KEY." - ) - - # Cache the response - self.cache.set(prompt, model, system_prompt, {"content": content}) - return content - - # ------------------------------------------------------------------ - # Private helpers - # ------------------------------------------------------------------ - - def _call_anthropic( - self, prompt: str, system_prompt: str, model: str, max_tokens: int - ) -> str: - """Call Anthropic API.""" - kwargs: dict = { - "model": model, - "max_tokens": max_tokens, - "messages": [{"role": "user", "content": prompt}], - } - if system_prompt: - kwargs["system"] = system_prompt - - # Only add reasoning_effort if the model supports it - reasoning_effort = os.environ.get("REASONING_EFFORT") - if reasoning_effort: - try: - kwargs["reasoning_effort"] = reasoning_effort - response = self._anthropic_client.messages.create(**kwargs) - except Exception as e: - if ( - "reasoning_effort" in str(e).lower() - or "unexpected" in str(e).lower() - ): - del kwargs["reasoning_effort"] - response = self._anthropic_client.messages.create(**kwargs) - else: - raise - else: - response = self._anthropic_client.messages.create(**kwargs) - - # Extract text from response content blocks - if hasattr(response, "content") and response.content: - block = response.content[0] - if hasattr(block, "text"): - return block.text - if hasattr(block, "type") and block.type == "thinking": - for b in response.content: - if hasattr(b, "text"): - return b.text - return str(block) - return str(response) - - def _call_openai( - self, prompt: str, system_prompt: str, model: str, max_tokens: int - ) -> str: - """Call OpenAI-compatible API.""" - messages: list[dict] = [] - if system_prompt: - messages.append({"role": "system", "content": system_prompt}) - messages.append({"role": "user", "content": prompt}) - - kwargs: dict = { - "model": model, - "messages": messages, - "max_tokens": max_tokens, - } - - # Only add reasoning_effort if supported - reasoning_effort = os.environ.get("REASONING_EFFORT") - if reasoning_effort: - try: - kwargs["reasoning_effort"] = reasoning_effort - response = self._openai_client.chat.completions.create( - **kwargs - ) - except Exception as e: - if ( - "reasoning_effort" in str(e).lower() - or "unexpected" in str(e).lower() - ): - del kwargs["reasoning_effort"] - response = self._openai_client.chat.completions.create( - **kwargs - ) - else: - raise - else: - response = self._openai_client.chat.completions.create(**kwargs) - - return response.choices[0].message.content diff --git a/tests/llm/judge.py b/tests/llm/judge.py deleted file mode 100644 index f9703b3..0000000 --- a/tests/llm/judge.py +++ /dev/null @@ -1,149 +0,0 @@ -""" -LLM-based log judgment for system tests. - -Evaluates logs against expected behavior using LLM semantic analysis. -Returns structured pass/fail results with reasoning. -""" - -import json -import re -import logging -from dataclasses import dataclass -from .client import LLMClient - -logger = logging.getLogger(__name__) - -JUDGE_SYSTEM_PROMPT = """\ -You are a test result judge for a CI/CD system called HoneyBiscuitWorkshop. - -Your job: Evaluate whether actual command output/logs match expected behavior. - -RULES: -1. You MUST respond with valid JSON in this exact format: - {"verdict": "pass" | "fail", "reason": "brief explanation"} -2. "pass" = the output confirms the expected behavior occurred -3. "fail" = the output shows the expected behavior did NOT occur, or shows an error -4. Ignore irrelevant noise (timestamps, debug lines, unrelated warnings) -5. Focus ONLY on whether the specific expected behavior is confirmed or contradicted -6. Be strict: if the evidence is ambiguous, verdict is "fail" -7. Do NOT include any text outside the JSON object""" - - -@dataclass -class JudgmentResult: - """Result of an LLM log judgment.""" - - verdict: str # "pass" or "fail" - reason: str - raw_response: str - - @property - def passed(self) -> bool: - return self.verdict == "pass" - - -def _extract_relevant_logs(full_log: str, max_chars: int = 4000) -> str: - """Extract relevant portions from logs, removing noise. - - Keeps the last *max_chars* characters (most recent / relevant). - Removes common noise patterns. - """ - lines = full_log.strip().split("\n") - - noise_patterns = [ - r"^\d{4}-\d{2}-\d{2}T", - r"^DEBUG ", - r"^TRACE ", - ] - filtered = [ - line - for line in lines - if not any(re.match(p, line) for p in noise_patterns) - ] - - text = "\n".join(filtered) - if len(text) > max_chars: - text = "...\n" + text[-max_chars:] - return text - - -def judge_log( - operation: str, - expected_behavior: str, - actual_log: str, - client: LLMClient | None = None, -) -> JudgmentResult: - """Judge whether actual logs match expected behavior. - - Args: - operation: Description of what was attempted - (e.g. ``"prebake bootstrap stage"``). - expected_behavior: What should have happened - (e.g. ``"user vulcan was created"``). - actual_log: The actual stdout / stderr output. - client: ``LLMClient`` instance (creates default if ``None``). - - Returns: - ``JudgmentResult`` with verdict and reason. - """ - if client is None: - client = LLMClient() - - if not client.available: - logger.warning("No LLM client available, returning fail verdict") - return JudgmentResult( - verdict="fail", - reason="No LLM client available for judgment", - raw_response="", - ) - - relevant_log = _extract_relevant_logs(actual_log) - - prompt = f"""\ -## Operation -{operation} - -## Expected Behavior -{expected_behavior} - -## Actual Output (relevant excerpts) -``` -{relevant_log} -``` - -Evaluate: Does the actual output confirm the expected behavior occurred? -Respond with JSON only.""" - - try: - response = client.complete( - prompt=prompt, - system_prompt=JUDGE_SYSTEM_PROMPT, - max_tokens=500, - ) - - # Parse JSON response — handle potential markdown code blocks - text = response.strip() - if text.startswith("```"): - lines = text.split("\n") - text = "\n".join(lines[1:-1]) if len(lines) > 2 else text - - result = json.loads(text) - return JudgmentResult( - verdict=result.get("verdict", "fail"), - reason=result.get("reason", "No reason provided"), - raw_response=response, - ) - except json.JSONDecodeError as e: - logger.error(f"Failed to parse LLM response as JSON: {e}") - return JudgmentResult( - verdict="fail", - reason=f"LLM response was not valid JSON: {response[:200]}", - raw_response=response, - ) - except Exception as e: - logger.error(f"LLM judgment failed: {e}") - return JudgmentResult( - verdict="fail", - reason=f"LLM call failed: {str(e)}", - raw_response="", - ) diff --git a/tests/probes/__init__.py b/tests/probes/__init__.py deleted file mode 100644 index 411f772..0000000 --- a/tests/probes/__init__.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Environment probes for verifying system state in workshop-baker tests. - -Each probe is a standalone function that can be called from any test. -Probes return bool or Optional[int/str]; they do not raise on failure. - -A probe accepts an optional *runner* callable (default ``subprocess.run``) -that decouples it from the execution context (host or Docker). -""" - -from .user import user_exists, user_uid, user_gid, user_in_group, user_shell -from .file import ( - file_exists, - file_absent, - dir_exists, - dir_absent, - file_permissions, - file_contains, - file_owned_by, - file_group, -) -from .package import binary_exists, binary_on_path, package_installed, package_version -from .process import process_running, process_count - -__all__ = [ - "user_exists", - "user_uid", - "user_gid", - "user_in_group", - "user_shell", - "file_exists", - "file_absent", - "dir_exists", - "dir_absent", - "file_permissions", - "file_contains", - "file_owned_by", - "file_group", - "package_installed", - "package_version", - "binary_exists", - "binary_on_path", - "process_running", - "process_count", -] diff --git a/tests/probes/file.py b/tests/probes/file.py deleted file mode 100644 index 86c4b05..0000000 --- a/tests/probes/file.py +++ /dev/null @@ -1,53 +0,0 @@ -"""File and directory existence / permission probes.""" - -import os -import stat -import grp -import pwd - - -def file_exists(path: str) -> bool: - """Check if *path* exists and is a regular file.""" - return os.path.isfile(path) - - -def dir_exists(path: str) -> bool: - """Check if *path* exists and is a directory.""" - return os.path.isdir(path) - - -def file_permissions(path: str) -> int: - """Return the permission mode of *path* as an octal int (e.g. ``0o755``). - - Raises ``FileNotFoundError`` if *path* doesn't exist. - """ - return stat.S_IMODE(os.stat(path).st_mode) - - -def file_contains(path: str, text: str) -> bool: - """Check if a file contains the specified text.""" - with open(path, "r", errors="replace") as f: - return text in f.read() - - -def file_owned_by(path: str, username: str) -> bool: - """Check if a file is owned by the specified user.""" - st = os.stat(path) - owner = pwd.getpwuid(st.st_uid).pw_name - return owner == username - - -def file_group(path: str) -> str: - """Get the group owner of a file.""" - st = os.stat(path) - return grp.getgrgid(st.st_gid).gr_name - - -def file_absent(path: str) -> bool: - """Check that *path* does not exist.""" - return not os.path.lexists(path) - - -def dir_absent(path: str) -> bool: - """Check that *path* does not exist or is not a directory.""" - return not os.path.isdir(path) diff --git a/tests/probes/package.py b/tests/probes/package.py deleted file mode 100644 index 81b4607..0000000 --- a/tests/probes/package.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Package / binary existence probes (cross-distro). - -Each probe accepts an optional *runner* callable with the same signature -as ``subprocess.run``. Defaults to running on the host; for Docker tests, -pass a runner that wraps ``docker run --rm ``. -""" - -import subprocess -from typing import Any, Callable - -Runner = Callable[..., Any] - - -def _run(cmd: list[str], runner: Runner = subprocess.run, **kw: Any) -> Any: - opts = {"capture_output": True, "text": True, "timeout": 30} - opts.update(kw) - return runner(cmd, **opts) - - -def _succeeded(r: Any) -> bool: - """.returncode==0 for CompletedProcess, or ["success"] for dict.""" - if hasattr(r, "returncode"): - return r.returncode == 0 - return bool(r.get("success", False)) - - -def binary_exists(path: str, runner: Runner = subprocess.run) -> bool: - """Check if a binary exists at *path* (e.g. ``/usr/bin/curl``). - - Uses ``test -f`` (POSIX, guaranteed available). - """ - return _succeeded(_run(["test", "-f", path], runner)) - - -def binary_on_path(name: str, runner: Runner = subprocess.run) -> bool: - """Check if a command is on PATH (via ``command -v``).""" - return _succeeded(_run(["sh", "-c", "command -v " + name], runner)) - - -# Backward-compatible aliases -package_installed = binary_exists -package_version = binary_on_path # used in __init__ export, now binary-based diff --git a/tests/probes/process.py b/tests/probes/process.py deleted file mode 100644 index ac432da..0000000 --- a/tests/probes/process.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Process existence probes.""" - -import os -import subprocess - - -def process_running(process_name: str) -> bool: - """Check if a process with the given name is running.""" - try: - result = subprocess.run( - ["pgrep", "-x", process_name], - capture_output=True, - text=True, - ) - return result.returncode == 0 - except FileNotFoundError: - # Fall back to /proc scan if pgrep not available - try: - for pid_dir in os.listdir("/proc"): - if pid_dir.isdigit(): - try: - with open(f"/proc/{pid_dir}/comm") as f: - if f.read().strip() == process_name: - return True - except (IOError, OSError): - continue - except OSError: - pass - return False - - -def process_count(process_name: str) -> int: - """Count processes with the given name.""" - try: - result = subprocess.run( - ["pgrep", "-c", "-x", process_name], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return int(result.stdout.strip()) - return 0 - except (FileNotFoundError, ValueError): - return 0 diff --git a/tests/probes/user.py b/tests/probes/user.py deleted file mode 100644 index 490643b..0000000 --- a/tests/probes/user.py +++ /dev/null @@ -1,41 +0,0 @@ -"""User existence and attribute probes.""" - -import pwd -import grp - - -def user_exists(username: str) -> bool: - """Check if a system user exists.""" - try: - pwd.getpwnam(username) - return True - except KeyError: - return False - - -def user_uid(username: str) -> int | None: - try: - return pwd.getpwnam(username).pw_uid - except KeyError: - return None - -def user_gid(username: str) -> int | None: - try: - return pwd.getpwnam(username).pw_gid - except KeyError: - return None - -def user_in_group(username: str, groupname: str) -> bool: - """Check if a user is a member of a group.""" - try: - group = grp.getgrnam(groupname) - return username in group.gr_mem - except KeyError: - return False - - -def user_shell(username: str) -> str | None: - try: - return pwd.getpwnam(username).pw_shell - except KeyError: - return None diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index 04fca7d..0000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -pytest>=8.0.0 -pytest-timeout>=2.2.0 -pytest-xdist>=3.5.0 -anthropic>=0.40.0,<0.90.0 -openai>=1.50.0,<2.0.0 -pyyaml>=6.0 diff --git a/tests/upm/__init__.py b/tests/upm/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/upm/test_go.py b/tests/upm/test_go.py deleted file mode 100644 index 250895d..0000000 --- a/tests/upm/test_go.py +++ /dev/null @@ -1,150 +0,0 @@ -"""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" diff --git a/tests/upm/test_python.py b/tests/upm/test_python.py deleted file mode 100644 index 7ed4a80..0000000 --- a/tests/upm/test_python.py +++ /dev/null @@ -1,163 +0,0 @@ -"""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']}" diff --git a/tests/upm/test_rust.py b/tests/upm/test_rust.py deleted file mode 100644 index d59b1f1..0000000 --- a/tests/upm/test_rust.py +++ /dev/null @@ -1,275 +0,0 @@ -"""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/.gitignore b/workshop-baker/.gitignore index 3644757..c9aef50 100644 --- a/workshop-baker/.gitignore +++ b/workshop-baker/.gitignore @@ -1,3 +1,4 @@ temp examples quicktest.sh +_env.sh diff --git a/workshop-baker/src/bare.rs b/workshop-baker/src/bare.rs new file mode 100644 index 0000000..27f9c1b --- /dev/null +++ b/workshop-baker/src/bare.rs @@ -0,0 +1,49 @@ +use std::path::PathBuf; + +use crate::cli::{BareCommands, Cli}; +use crate::error::CliError; +use crate::{bake::bake, finalize::finalize, prebake::prebake}; +use workshop_engine::{EventSender, ExecutionContext}; + +fn or_workshop(path: &Option, name: &str) -> PathBuf { + path + .clone() + .unwrap_or_else(|| PathBuf::from(format!(".workshop/{}", name))) +} + +pub async fn run_bare( + cli: &Cli, + command: &BareCommands, + ctx: &mut ExecutionContext, + event_tx: EventSender, + pipeline: &str, + build_id: &str, +) -> Result<(), CliError> { + let (stage, config) = match command { + BareCommands::Prebake { .. } => ("prebake", or_workshop(&cli.prebake, "prebake.yml")), + BareCommands::Bake { .. } => ("bake", or_workshop(&cli.bake, "bake.sh")), + BareCommands::Finalize { .. } => { + ("finalize", or_workshop(&cli.finalize, "finalize.yml")) + } + }; + + let prebake_path = or_workshop(&cli.prebake, "prebake.yml"); + let bake_base = or_workshop(&cli.bake_base, "bake_base.sh"); + + ctx.task_id = format!("{}-{}-{}", pipeline, build_id, stage); + ctx.pipeline_name = pipeline.to_string(); + ctx.standalone = true; + + match command { + BareCommands::Prebake { .. } => { + prebake(&config, cli, None, ctx, event_tx, None).await?; + } + BareCommands::Bake { .. } => { + bake(&config, &bake_base, &prebake_path, cli, ctx, event_tx).await?; + } + BareCommands::Finalize { .. } => { + finalize(&config, cli, ctx, event_tx).await?; + } + } + Ok(()) +} diff --git a/workshop-baker/src/error.rs b/workshop-baker/src/error.rs new file mode 100644 index 0000000..f46e350 --- /dev/null +++ b/workshop-baker/src/error.rs @@ -0,0 +1,30 @@ +use thiserror::Error; +use workshop_engine::HasExitCode; + +#[derive(Error, Debug)] +pub enum CliError { + #[error("{0}")] + Prebake(#[from] crate::prebake::error::PrebakeError), + #[error("{0}")] + Bake(#[from] crate::bake::error::BakeError), + #[error("{0}")] + Finalize(#[from] crate::finalize::FinalizeError), + #[error("{0}")] + General(anyhow::Error), +} + +impl From for CliError { + fn from(e: anyhow::Error) -> Self { + CliError::General(e) + } +} +impl CliError { + pub fn exit_code(&self) -> i32 { + match self { + CliError::Prebake(e) => e.exit_code(), + CliError::Bake(e) => e.exit_code(), + CliError::Finalize(e) => e.exit_code(), + CliError::General(_) => 1, + } + } +} diff --git a/workshop-baker/src/finalize/config.rs b/workshop-baker/src/finalize/config.rs index 14fec81..ec706c6 100644 --- a/workshop-baker/src/finalize/config.rs +++ b/workshop-baker/src/finalize/config.rs @@ -135,16 +135,41 @@ pub struct NotificationTemplate { } /// Notification configuration container -#[derive(Debug, Deserialize, Serialize, Clone, Default)] +#[derive(Debug, Serialize, Clone, Default)] pub struct NotificationConfig { /// Template definitions for notification content #[serde(default)] pub templates: HashMap, - #[serde(flatten)] + /// Priority groups (YAML keys like `0`, `1` are parsed as u32). + /// Custom Deserialize handles serde_yaml string→u32 conversion. + #[serde(default)] pub groups: HashMap>, } +/// Serde helper: YAML map keys are always strings, convert to u32. +impl<'de> serde::Deserialize<'de> for NotificationConfig { + fn deserialize(deserializer: D) -> Result + where D: serde::Deserializer<'de> + { + #[derive(serde::Deserialize)] + struct Helper { + #[serde(default)] + templates: HashMap, + #[serde(flatten)] + groups_raw: HashMap>, + } + let h = Helper::deserialize(deserializer)?; + let groups: HashMap = h.groups_raw.into_iter() + .map(|(k, v)| { + k.parse::().map(|n| (n, v)) + .map_err(|_| serde::de::Error::custom(format!("priority key must be integer, got {:?}", k))) + }) + .collect::>()?; + Ok(NotificationConfig { templates: h.templates, groups }) + } +} + /// Stage-triggered notification configuration #[derive(Debug, Deserialize, Serialize, Clone)] pub struct OnFinishOf { diff --git a/workshop-baker/src/finalize/plugin/internal.rs b/workshop-baker/src/finalize/plugin/internal.rs index 2c943c5..8e54751 100644 --- a/workshop-baker/src/finalize/plugin/internal.rs +++ b/workshop-baker/src/finalize/plugin/internal.rs @@ -27,5 +27,9 @@ pub fn get_internal_plugin() -> Vec { name: "webhook", entry: Box::new(webhook::Webhook), }, + InternalPlugin { + name: "dummy", + entry: Box::new(dummy::Dummy), + }, ] } diff --git a/workshop-baker/src/finalize/plugin/internal/dummy.rs b/workshop-baker/src/finalize/plugin/internal/dummy.rs index 976b80c..719fee4 100644 --- a/workshop-baker/src/finalize/plugin/internal/dummy.rs +++ b/workshop-baker/src/finalize/plugin/internal/dummy.rs @@ -4,10 +4,10 @@ use workshop_engine::{EventSender, ExecutionContext}; use crate::{ finalize::plugin::AsyncPluginFn}; use async_trait::async_trait; -pub struct _Dummy; +pub struct Dummy; #[async_trait] -impl AsyncPluginFn for _Dummy { +impl AsyncPluginFn for Dummy { async fn call( &self, _metadata: &PluginMetadata, @@ -15,6 +15,7 @@ impl AsyncPluginFn for _Dummy { _ctx: &ExecutionContext, _event_tx: &EventSender, ) -> Result<(), PluginError> { + log::info!("[NOTIFY_DEBUG] dummy plugin called successfully"); Ok(()) } } diff --git a/workshop-baker/src/finalize/plugin/internal/mail.rs b/workshop-baker/src/finalize/plugin/internal/mail.rs index 9b094bb..7c11c9c 100644 --- a/workshop-baker/src/finalize/plugin/internal/mail.rs +++ b/workshop-baker/src/finalize/plugin/internal/mail.rs @@ -10,11 +10,17 @@ use std::time::Duration; use tokio::time::timeout; mod config; -mod template; - pub use config::*; -use template::*; +/// Mail notification plugin — SSR only. +/// +/// Expects `_rendered_content` in the argument (injected by +/// `notification_handler`'s Server renderer). The subject must be +/// provided via the notification method's config (`subject` field). +/// +/// In standalone/bare mode the user MUST supply templates via +/// `finalize.yml notification.templates`. In worker (c/d) mode the +/// daemon may provide ws-base defaults. pub struct Mail; #[async_trait] @@ -23,25 +29,24 @@ impl AsyncPluginFn for Mail { &self, _metadata: &PluginMetadata, argument: serde_yaml::Value, - ctx: &ExecutionContext, + _ctx: &ExecutionContext, _event_tx: &EventSender, ) -> Result<(), PluginError> { - // SSR mode: use pre-rendered content from notification_handler - let rendered_content = argument.get("_rendered_content").and_then(|v| v.as_str()).map(|s| s.to_string()); - if rendered_content.is_some() { - let config: MailConfig = serde_yaml::from_value(argument) - .map_err(|e| PluginError::GeneralError(format!("Invalid mail config: {}", e)))?; - if let Err(errors) = config.validate() { - return Err(PluginError::GeneralError(format!( - "Mail config validation failed: {:?}", - errors - ))); + let rendered_content = argument + .get("_rendered_content") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let body_html = match rendered_content { + Some(content) => content, + None => { + return Err(PluginError::GeneralError( + "Mail plugin requires SSR mode: _rendered_content not found in argument. " + .to_string() + + "Ensure notification.templates are configured in finalize.yml.", + )); } - let subject = config.subject.as_deref().unwrap_or(DefaultTemplates::SUBJECT); - let body_html = rendered_content.unwrap(); - let email = build_email(&config, subject.to_string(), body_html, String::new())?; - return send_email(&config, email).await; - } + }; let config: MailConfig = serde_yaml::from_value(argument) .map_err(|e| PluginError::GeneralError(format!("Invalid mail config: {}", e)))?; @@ -52,48 +57,10 @@ impl AsyncPluginFn for Mail { ))); } - if let Err(errors) = config.validate() { - return Err(PluginError::GeneralError(format!( - "Mail config validation failed: {:?}", - errors - ))); - } - - let template_data = prepare_template_data(ctx); - - let (subject_template, body_template_html, body_template_text) = match config.schema { - Schema::Default => { - let subject = config - .subject - .as_deref() - .unwrap_or(DefaultTemplates::SUBJECT); - let body_html = config - .body - .as_deref() - .unwrap_or(DefaultTemplates::BODY_HTML); - ( - subject.to_string(), - body_html.to_string(), - DefaultTemplates::BODY_TEXT.to_string(), - ) - } - Schema::Custom => { - let subject = config.subject.clone().unwrap(); - let body_html = config.body.clone().unwrap(); - (subject, body_html.clone(), body_html) - } - }; - - let subject = render_template(&subject_template, &template_data) - .map_err(|e| PluginError::GeneralError(format!("Failed to render subject: {}", e)))?; - let body_html = render_template(&body_template_html, &template_data) - .map_err(|e| PluginError::GeneralError(format!("Failed to render body_html: {}", e)))?; - let body_text = render_template(&body_template_text, &template_data).map_err(|e| { - PluginError::GeneralError(format!("Failed to render body_text(?): {}", e)) - })?; + let subject = config.subject.clone().unwrap_or_else(|| "[HBW] {{ pipeline.name }}".to_string()); + let body_text = String::new(); // SSR provides HTML only; plaintext is optional let email = build_email(&config, subject, body_html, body_text)?; - send_email(&config, email).await } } diff --git a/workshop-baker/src/finalize/plugin/internal/mail/config.rs b/workshop-baker/src/finalize/plugin/internal/mail/config.rs index f450cd5..692563d 100644 --- a/workshop-baker/src/finalize/plugin/internal/mail/config.rs +++ b/workshop-baker/src/finalize/plugin/internal/mail/config.rs @@ -113,21 +113,9 @@ fn default_connect_timeout_ms() -> u64 { 30_000 } -/// Mail schema type determining required fields. -#[derive(Debug, Deserialize, Clone)] -#[serde(rename_all = "lowercase")] -pub enum Schema { - /// Default schema with minimal required fields. - Default, - /// Custom schema requiring explicit subject and body. - Custom, -} - -fn default_schema() -> Schema { - Schema::Default -} - -/// Complete mail notification configuration. +/// Complete mail notification configuration (SSR-only). +/// The email body comes from `_rendered_content` (injected by notification_handler). +/// Subject is optional — falls back to a minimal default. #[derive(Debug, Deserialize, Clone)] pub struct MailConfig { /// Primary recipients. @@ -143,36 +131,19 @@ pub struct MailConfig { /// Reply-to address. #[serde(default)] pub reply_to: Option, - /// Schema type determining validation rules. - #[serde(default = "default_schema")] - pub schema: Schema, - /// Email subject (required for custom schema). + /// Email subject (template string). Falls back to "[HBW] {{ pipeline.name }}". pub subject: Option, - /// Email body content (required for custom schema). - pub body: Option, /// SMTP server configuration. pub smtp: SmtpConfig, } impl MailConfig { - /// Validates the mail configuration according to schema rules. - /// Returns Ok(()) if valid, or Err(vec) with error messages if invalid. + /// Validates the mail configuration. pub fn validate(&self) -> Result<(), Vec> { let mut errors = Vec::new(); - - if matches!(self.schema, Schema::Custom) { - if self.subject.is_none() { - errors.push("subject: required when schema=custom".to_string()); - } - if self.body.is_none() { - errors.push("body: required when schema=custom".to_string()); - } - } - if self.to.to_vec().is_empty() { errors.push("to: must have at least one recipient".to_string()); } - if errors.is_empty() { Ok(()) } else { @@ -181,51 +152,6 @@ impl MailConfig { } } -/// Built-in email notification templates. -pub struct DefaultTemplates; - -impl DefaultTemplates { - /// Default subject template with build status. - pub const SUBJECT: &'static str = - "[{{ build.status | upper }}] {{ pipeline.name }} #{{ build.id }}"; - - /// Default HTML email body template. - pub const BODY_HTML: &'static str = r#" - - -

- Build {{ build.status | title }} -

- - - - - - - -
Pipeline:{{ pipeline.name }}
Build ID:{{ build.id }}
Status:{{ build.status }}
Duration:{{ build.duration }}
Author:{{ commit.author }}
Message:{{ commit.message }}
-

- - View Build Details - -

- -"#; - - /// Default plain text email body template. - pub const BODY_TEXT: &'static str = r#"Build {{ build.status | title }} -============================== -Pipeline: {{ pipeline.name }} -Build ID: {{ build.id }} -Status: {{ build.status }} -Duration: {{ build.duration }} -Commit: {{ commit.hash }} -Author: {{ commit.author }} -Message: {{ commit.message }} - -View details: {{ build.url }}"#; -} - #[cfg(test)] mod tests { use super::*; @@ -270,33 +196,14 @@ mod tests { } #[test] - fn test_recipients_single_to_vec() { - let recipients = Recipients::Single("test@example.com".to_string()); - let vec = recipients.to_vec(); - assert_eq!(vec, vec!["test@example.com"]); - } - - #[test] - fn test_recipients_multiple_to_vec() { - let recipients = Recipients::Multiple(vec![ - "a@example.com".to_string(), - "b@example.com".to_string(), - ]); - let vec = recipients.to_vec(); - assert_eq!(vec, vec!["a@example.com", "b@example.com"]); - } - - #[test] - fn test_mail_config_validate_valid_default_schema() { + fn test_mail_config_validate_valid_config() { let config = MailConfig { to: Recipients::Single("to@example.com".to_string()), cc: None, bcc: None, from: EmailAddress::String("from@example.com".to_string()), reply_to: None, - schema: Schema::Default, - subject: None, - body: None, + subject: Some("Test Subject".to_string()), smtp: SmtpConfig { host: "smtp.example.com".to_string(), port: 587, @@ -312,88 +219,6 @@ mod tests { assert!(config.validate().is_ok()); } - #[test] - fn test_mail_config_validate_valid_custom_schema() { - let config = MailConfig { - to: Recipients::Single("to@example.com".to_string()), - cc: None, - bcc: None, - from: EmailAddress::String("from@example.com".to_string()), - reply_to: None, - schema: Schema::Custom, - subject: Some("Test Subject".to_string()), - body: Some("Test Body".to_string()), - smtp: SmtpConfig { - host: "smtp.example.com".to_string(), - port: 587, - auth: AuthConfig { - auth_type: AuthType::None, - username: "".to_string(), - password: "".to_string(), - }, - encryption: Encryption::Tls, - connect_timeout_ms: 30_000, - }, - }; - assert!(config.validate().is_ok()); - } - - #[test] - fn test_mail_config_validate_custom_schema_missing_subject() { - let config = MailConfig { - to: Recipients::Single("to@example.com".to_string()), - cc: None, - bcc: None, - from: EmailAddress::String("from@example.com".to_string()), - reply_to: None, - schema: Schema::Custom, - subject: None, - body: Some("Test Body".to_string()), - smtp: SmtpConfig { - host: "smtp.example.com".to_string(), - port: 587, - auth: AuthConfig { - auth_type: AuthType::None, - username: "".to_string(), - password: "".to_string(), - }, - encryption: Encryption::Tls, - connect_timeout_ms: 30_000, - }, - }; - let result = config.validate(); - assert!(result.is_err()); - assert!(result.unwrap_err().iter().any(|e| e.contains("subject"))); - } - - #[test] - fn test_mail_config_validate_custom_schema_missing_body() { - let config = MailConfig { - to: Recipients::Single("to@example.com".to_string()), - cc: None, - bcc: None, - from: EmailAddress::String("from@example.com".to_string()), - reply_to: None, - schema: Schema::Custom, - subject: Some("Test Subject".to_string()), - body: None, - smtp: SmtpConfig { - host: "smtp.example.com".to_string(), - port: 587, - auth: AuthConfig { - auth_type: AuthType::None, - username: "".to_string(), - password: "".to_string(), - }, - encryption: Encryption::Tls, - connect_timeout_ms: 30_000, - }, - }; - let result = config.validate(); - assert!(result.is_err()); - assert!(result.unwrap_err().iter().any(|e| e.contains("body"))); - } - #[test] fn test_mail_config_validate_empty_recipients() { let config = MailConfig { @@ -402,9 +227,7 @@ mod tests { bcc: None, from: EmailAddress::String("from@example.com".to_string()), reply_to: None, - schema: Schema::Default, subject: None, - body: None, smtp: SmtpConfig { host: "smtp.example.com".to_string(), port: 587, @@ -422,6 +245,23 @@ mod tests { assert!(result.unwrap_err().iter().any(|e| e.contains("recipient"))); } + #[test] + fn test_recipients_single_to_vec() { + let recipients = Recipients::Single("test@example.com".to_string()); + let vec = recipients.to_vec(); + assert_eq!(vec, vec!["test@example.com"]); + } + + #[test] + fn test_recipients_multiple_to_vec() { + let recipients = Recipients::Multiple(vec![ + "a@example.com".to_string(), + "b@example.com".to_string(), + ]); + let vec = recipients.to_vec(); + assert_eq!(vec, vec!["a@example.com", "b@example.com"]); + } + #[test] fn test_email_address_serde_string() { let json = r#""test@example.com""#; @@ -476,15 +316,4 @@ mod tests { let t: Test = serde_json::from_str(json).unwrap(); assert!(matches!(t.encryption, Encryption::Tls)); } - - #[test] - fn test_schema_serde() { - #[derive(serde::Deserialize, Debug)] - struct Test { - schema: Schema, - } - let json = r#"{"schema":"custom"}"#; - let t: Test = serde_json::from_str(json).unwrap(); - assert!(matches!(t.schema, Schema::Custom)); - } } diff --git a/workshop-baker/src/finalize/plugin/internal/mail/template.rs b/workshop-baker/src/finalize/plugin/internal/mail/template.rs deleted file mode 100644 index f1d449c..0000000 --- a/workshop-baker/src/finalize/plugin/internal/mail/template.rs +++ /dev/null @@ -1,36 +0,0 @@ -pub use crate::notify::template::*; - -use workshop_engine::ExecutionContext; -use std::collections::HashMap; - -/// Legacy function kept for backward compatibility - delegates to build_template_context -pub fn prepare_template_data(ctx: &ExecutionContext) -> HashMap { - let mut data = HashMap::new(); - data.insert( - "pipeline".to_string(), - serde_json::json!({ - "name": ctx.pipeline_name, - }), - ); - data.insert( - "build".to_string(), - serde_json::json!({ - "id": ctx.task_id, - "status": "success", - "status_color": "green", - "duration": "0s", - "url": "", - }), - ); - data.insert( - "commit".to_string(), - serde_json::json!({ - "hash": "", - "short_hash": "", - "message": "", - "author": "", - "author_email": "", - }), - ); - data -} diff --git a/workshop-baker/src/lib.rs b/workshop-baker/src/lib.rs index ece5285..05067c3 100644 --- a/workshop-baker/src/lib.rs +++ b/workshop-baker/src/lib.rs @@ -1,5 +1,7 @@ +pub mod bare; pub mod bake; pub mod daemon; +pub mod error; pub mod finalize; pub mod notify; pub mod prebake; @@ -35,6 +37,23 @@ pub mod cli { #[arg(long, default_value = "false", env = "HBW_DRY_RUN")] pub dry_run: bool, + /// Override prebake config path + #[arg(long)] + pub prebake: Option, + /// Override bake script path + #[arg(long)] + pub bake: Option, + /// Override bake_base.sh path + #[arg(long)] + pub bake_base: Option, + /// Override finalize config path + #[arg(long)] + pub finalize: Option, + + /// Debug features (comma-separated). "notify" enables notification debug logging. + #[arg(long, env = "HBW_DEBUG")] + pub debug: Option, + #[command(subcommand)] pub command: Option, } @@ -47,28 +66,14 @@ pub mod cli { command: BareCommands, }, - /// Run in daemon mode Daemon {}, - } #[derive(Subcommand, Debug)] pub enum BareCommands { - /// Prepare a task - Prebake { prebake: PathBuf }, - - /// Run a task - Bake { - /// Path to the script to execute - script: PathBuf, - /// Path to bake_base.sh - #[arg(short, long, default_value = "./bake_base.sh")] - bake_base: PathBuf, - #[arg(short, long, default_value = "./prebake.yml")] - prebake: PathBuf, - }, - - /// Finish a task - Finalize { finalize: String }, + Prebake {}, + Bake {}, + Finalize {}, } + } diff --git a/workshop-baker/src/main.rs b/workshop-baker/src/main.rs index ff80b89..f37db0f 100644 --- a/workshop-baker/src/main.rs +++ b/workshop-baker/src/main.rs @@ -1,11 +1,85 @@ -use std::path::Path; +use std::path::PathBuf; use workshop_baker::{ - bake::bake, cli::{BareCommands, Cli, Commands}, finalize::finalize, notify::notify, prebake::prebake + bake::bake, cli::{Cli, Commands, Parser}, + error::CliError, + finalize::finalize, notify::notify, prebake::prebake, }; -use workshop_engine::{EventSender, ExecutionContext, error::HasExitCode}; -use workshop_baker::Parser; +use workshop_baker::bare::run_bare; +use workshop_baker::notify::queue::NotificationQueue; +use workshop_engine::{EventSender, ExecutionContext}; +fn or_workshop(path: &Option, name: &str) -> PathBuf { + path.clone().unwrap_or_else(|| PathBuf::from(format!(".workshop/{}", name))) +} + +async fn worker_main(cli: Cli) -> Result<(), CliError> { + let prebake_path = cli.prebake.clone().unwrap_or_else(|| { + eprintln!("Error: --prebake is required in worker standalone mode"); + std::process::exit(1); + }); + let bake_path = cli.bake.clone().unwrap_or_else(|| { + eprintln!("Error: --bake is required in worker standalone mode"); + std::process::exit(1); + }); + let finalize_path = cli.finalize.clone().unwrap_or_else(|| { + eprintln!("Error: --finalize is required in worker standalone mode"); + std::process::exit(1); + }); + + log::info!("Worker standalone mode"); + + let mut ctx = ExecutionContext { + standalone: true, + task_id: format!("{}-{}-worker", cli.pipeline, cli.build_id), + pipeline_name: cli.pipeline.clone(), + username: cli.username.clone(), + dry_run: cli.dry_run, + privileged: false, + ..Default::default() + }; + + let (tx, event_rx) = tokio::sync::mpsc::channel(256); + let event_tx: EventSender = Some(tx); + let _event_handle = tokio::spawn(workshop_baker::prebake::event::event_receiver( + event_rx, cli.pipeline.clone(), cli.build_id.clone(), + )); + + let (to_tx, to_rx) = tokio::sync::mpsc::channel(256); + let (retry_tx, retry_rx) = tokio::sync::mpsc::channel(256); + + let notify_path = finalize_path.clone(); + let notify_ctx = ctx.clone(); + let notify_etx = event_tx.clone(); + let debug = cli.debug.clone(); + let notify_handle = tokio::spawn(async move { + notify(¬ify_path, &mut notify_ctx.clone(), notify_etx, to_rx, retry_tx, debug).await + }); + + let nevent_tx = to_tx.clone(); + let mut queue = NotificationQueue::new(to_tx, retry_rx); + + let pipe_result: anyhow::Result<()> = async { + prebake(&prebake_path, &cli, None, &mut ctx, event_tx.clone(), Some(nevent_tx)).await?; + let bake_base_path = or_workshop(&cli.bake_base, "bake_base.sh"); + let pb_path = or_workshop(&cli.prebake, "prebake.yml"); + bake(&bake_path, &bake_base_path, &pb_path, &cli, &mut ctx, event_tx.clone()).await?; + finalize(&finalize_path, &cli, &mut ctx, event_tx.clone()).await?; + Ok(()) + }.await; + + if let Err(e) = pipe_result { + log::error!("Pipeline stage failed: {}", e); + return Err(CliError::General(e)); + } + + log::info!("Pipeline complete. Draining notifications..."); + if let Err(e) = queue.drain().await { + log::error!("Notification queue error: {}", e); + } + let _ = notify_handle.await; + Ok(()) +} #[tokio::main] async fn main() { @@ -14,85 +88,45 @@ async fn main() { if cli.dry_run { log::warn!("Dry run enabled, no changes will be made."); } + + let pipeline = cli.pipeline.clone(); + let build_id = cli.build_id.clone(); + match &cli.command { Some(Commands::Bare { command }) => { - - log::info!("Running in bare mode."); - - let (stage, privileged) = match command { - BareCommands::Prebake { .. } => ("init", true), - BareCommands::Bake { .. } => ("bake", false), - BareCommands::Finalize { .. } => ("finalize", false), - }; let mut ctx = ExecutionContext { standalone: true, - task_id: format!("{}-{}-{}", cli.pipeline, cli.build_id, stage), - pipeline_name: cli.pipeline.clone(), + task_id: format!("{}-{}-bare", pipeline, build_id), + pipeline_name: pipeline.clone(), username: cli.username.clone(), dry_run: cli.dry_run, - privileged, + privileged: false, ..Default::default() }; let (tx, event_rx) = tokio::sync::mpsc::channel(256); let event_tx: EventSender = Some(tx); - let handle = tokio::spawn(workshop_baker::prebake::event::event_receiver( - event_rx, - cli.pipeline.clone(), - cli.build_id.clone(), + let event_handle = tokio::spawn(workshop_baker::prebake::event::event_receiver( + event_rx, pipeline.clone(), build_id.clone(), )); - let result: anyhow::Result<()> = match command { - BareCommands::Prebake { prebake: config_path } => { - prebake(config_path, &cli, None, &mut ctx, event_tx).await - } - BareCommands::Bake { - script, - bake_base, - prebake: prebake_path, - } => bake(script, bake_base, prebake_path, &cli, &mut ctx, event_tx) - .await - .map_err(|e| e.into()), - BareCommands::Finalize { finalize: config_str } => { - finalize(Path::new(&config_str), &cli, &mut ctx, event_tx) - .await - .map_err(|e| e.into()) - } - }; + let result = run_bare(&cli, command, &mut ctx, event_tx, &pipeline, &build_id).await; + drop(ctx); - let _ = handle.await; - dbg!(&result); - if let Err(ref e) = result { - let code = match command { - BareCommands::Prebake { .. } => e - .downcast_ref::() - .map(|pe| pe.exit_code()) - .unwrap_or(1), - BareCommands::Bake { .. } => e - .downcast_ref::() - .map(|be| be.exit_code()) - .unwrap_or(1), - BareCommands::Finalize { .. } => e - .downcast_ref::() - .map(|fe| fe.exit_code()) - .unwrap_or(1), - }; - log::error!("{} stage failed: {}", stage, e); - std::process::exit(code); + let _ = event_handle.await; + + if let Err(e) = result { + log::error!("{}", e); + std::process::exit(e.exit_code()); } } Some(Commands::Daemon {}) => { - log::info!("Running in daemon mode."); - unimplemented!("Daemon mode requires heavy refactor!"); + unimplemented!("Daemon mode not implemented"); } None => { - let process_name = std::env::args().next().expect("Process name should always exist!"); - if process_name.contains("bakerd") { - log::info!("Running in daemon mode."); - unimplemented!("Daemon mode requires heavy refactor!"); + if let Err(e) = worker_main(cli).await { + log::error!("{}", e); + std::process::exit(e.exit_code()); } - // Main Daemon/Client loop - log::info!("Running in worker mode."); - // notify(cli., ctx, event_tx, rx, retry_tx) } } } diff --git a/workshop-baker/src/notify.rs b/workshop-baker/src/notify.rs index ce5978f..5499bc2 100644 --- a/workshop-baker/src/notify.rs +++ b/workshop-baker/src/notify.rs @@ -71,15 +71,34 @@ pub async fn notify( _event_tx: EventSender, mut rx: NotificationEventReceiver, retry_tx: NotificationEventSender, + debug: Option, ) -> anyhow::Result<()> { let mut finalize = parse(finalize_path)?; validate(&finalize)?; - let plugin_list = extract_plugin(&finalize); + let mut plugin_list = extract_plugin(&finalize); + + let is_debug = debug.as_deref().unwrap_or("").contains("notify"); if plugin_list.is_empty() { - log::warn!("No notification method provided, skipping notification"); - // TODO: send event to event_tx - return Ok(()); + if is_debug { + log::info!("[NOTIFY_DEBUG] Injecting dummy plugin for debug observation"); + plugin_list.insert("dummy".to_string()); + // Insert a dummy method so the notification system has at least one entry + let mut methods = std::collections::HashMap::new(); + methods.insert( + "dummy".to_string(), + crate::finalize::NotificationMethod { + template: crate::finalize::NotificationTemplate::default(), + trigger: vec![], + config: serde_yaml::Value::Mapping(serde_yaml::Mapping::new()), + policy: vec![], + }, + ); + finalize.notification.groups.insert(0, methods); + } else { + log::warn!("No notification method provided, skipping notification"); + return Ok(()); + } } if ctx.standalone { @@ -131,8 +150,21 @@ pub async fn notify( loop { // Wait for first event - let Some(first) = rx.recv().await else { - break; + let first = if is_debug { + // Debug mode: don't block forever — timeout prevents deadlock + match tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()).await { + Ok(Some(event)) => event, + Ok(None) => break, + Err(_) => { + log::info!("[NOTIFY_DEBUG] No events within timeout, shutting down"); + break; + } + } + } else { + match rx.recv().await { + Some(event) => event, + None => break, + } }; let mut events = vec![first]; @@ -141,6 +173,13 @@ pub async fn notify( events.push(event); } + if debug.as_deref().unwrap_or("").contains("notify") { + for ev in &events { + log::info!("[NOTIFY_DEBUG] stage={:?} substage={} outcome={:?} priority={}", + ev.stage, ev.substage, ev.outcome, ev.priority); + } + } + let results = notification_handler(¬ify_plugins, ¬ification_rules, &events, &finalize.notification.templates, ctx).await; let mut na: Vec = vec![]; @@ -345,12 +384,13 @@ async fn notification_handler( ctx: &ExecutionContext, ) -> Vec<(String, MethodStatus)> { let mut results: Vec<(String, MethodStatus)> = vec![]; + log::info!("[NOTIFY] handler called with {} events, {} priority groups", events.len(), config.len()); let mut buckets: std::collections::HashMap<(String, usize), Vec<&NotificationEvent>> = std::collections::HashMap::new(); - for (_priority, ruleset) in config.iter() { for (method, rules) in ruleset.iter() { + log::info!("[NOTIFY] method={} rules_count={}", method, rules.len()); for event in events { if let Some(ref method_rules) = event.target.rules { if !method_rules.contains(method) { @@ -358,10 +398,15 @@ async fn notification_handler( } } for (rule_id, rule) in rules.iter().enumerate() { + log::info!("[NOTIFY] rule_id={} enabled={} trigger_len={}", rule_id, rule.enabled, rule.trigger.len()); + eprintln!("[CHK-ENABLED] entering enabled check"); if !rule.enabled { + eprintln!("[CHK-ENABLED] enabled=false, continuing"); continue; } + eprintln!("[BEFORE-ACCEPT] method={} rule={} event={}.{}", method, rule_id, event.stage, event.substage); if rule.accept(&event.outcome, &event.stage, &event.substage) { + log::info!("[NOTIFY] MATCH: method={} rule={} event={}.{}", method, rule_id, event.stage, event.substage); buckets .entry((method.clone(), rule_id)) .or_default() @@ -376,6 +421,7 @@ async fn notification_handler( return results; } + log::info!("[NOTIFY] processing {} buckets", buckets.len()); for ((method, rule_id), bucket_events) in buckets { let rule = match find_rule(config, &method, rule_id) { Ok(r) => r, @@ -665,12 +711,12 @@ fn extract_overrides(overrides: &serde_yaml::Value) -> (bool, u32, std::time::Du } fn parse_rules(config: &NotificationConfig) -> NotificationRulesMap { + log::info!("[NOTIFY] parse_rules: groups={}", config.groups.len()); let mut rules = NotificationRulesMap::default(); for (priority, group) in &config.groups { let mut ruleset: HashMap> = HashMap::new(); for (method_name, method) in group { let mut method_rules: Vec = Vec::new(); - if method.policy.is_empty() { let trigger: Vec = method .trigger diff --git a/workshop-baker/src/notify/types.rs b/workshop-baker/src/notify/types.rs index e646b24..9d46993 100644 --- a/workshop-baker/src/notify/types.rs +++ b/workshop-baker/src/notify/types.rs @@ -77,6 +77,10 @@ impl ParsedTrigger { impl NotificationRule { pub fn accept(&self, result: &StageOutcome, stage: &StagePhase, substage: &str) -> bool { + if self.trigger.is_empty() { + eprintln!("[ACCEPT] trigger empty, returning true"); + return true; + } self.trigger .iter() .any(|t| t.accept(result, stage, substage)) diff --git a/workshop-baker/src/prebake.rs b/workshop-baker/src/prebake.rs index 3c65292..e4e51aa 100644 --- a/workshop-baker/src/prebake.rs +++ b/workshop-baker/src/prebake.rs @@ -33,6 +33,9 @@ use crate::{ cli::Cli, prebake::{error::PrebakeError, security::get_drop_after, stage::PrebakeStage}, }; +use crate::notify::types::NotificationEventSender; +use crate::types::buildstatus::{StageOutcome, StagePhase}; +use uuid::Uuid; use chrono::Utc; use std::path::Path; use workshop_engine::{EventSender, ExecutionContext}; @@ -92,6 +95,7 @@ pub async fn prebake( stage: Option, ctx: &mut ExecutionContext, event_tx: EventSender, + nevent_tx: Option, ) -> anyhow::Result<()> { // Initialize let prebake_content = std::fs::read_to_string(prebake_path).inspect_err(|e| { @@ -131,6 +135,7 @@ pub async fn prebake( let result = stage::bootstrap::bootstrap(&prebake, &osinfo, ctx, &event_tx).await; result?; } + send_stage_event(&nevent_tx, StagePhase::Prebake, "bootstrap", StageOutcome::Success).await; // EarlyHook stage if stage <= PrebakeStage::EarlyHook { @@ -145,6 +150,7 @@ pub async fn prebake( let result = stage::hook::hook(earlyhook, ctx, event_tx.clone()).await; result?; } + send_stage_event(&nevent_tx, StagePhase::Prebake, "earlyhook", StageOutcome::Success).await; if let Some(dependencies) = prebake.dependencies { let endpoint = dependencies @@ -215,6 +221,7 @@ pub async fn prebake( let result = stage::depssystem::depssystem(&system_config, ctx, event_tx.clone()).await; result?; } + send_stage_event(&nevent_tx, StagePhase::Prebake, "depssystem", StageOutcome::Success).await; if let Some(user) = dependencies.user && stage <= PrebakeStage::DepsUser @@ -227,6 +234,7 @@ pub async fn prebake( result?; } } + send_stage_event(&nevent_tx, StagePhase::Prebake, "depsuser", StageOutcome::Success).await; // LateHook stage if stage <= PrebakeStage::LateHook { @@ -240,11 +248,37 @@ pub async fn prebake( }; let result = stage::hook::hook(latehook, ctx, event_tx.clone()).await; result?; + send_stage_event(&nevent_tx, StagePhase::Prebake, "latehook", StageOutcome::Success).await; } // Ready stage ctx.task_id = format!("{}-{}-ready", cli.pipeline, cli.build_id); + send_stage_event(&nevent_tx, StagePhase::Prebake, "ready", StageOutcome::Success).await; log::info!("Prebake done!"); Ok(()) + } + +/// Send a stage completion event to the notification queue. +async fn send_stage_event( + nevent_tx: &Option, + stage: StagePhase, + substage: &str, + outcome: StageOutcome, +) { + if let Some(tx) = nevent_tx { + let event = crate::notify::types::NotificationEvent { + id: Uuid::new_v4(), + stage, + substage: substage.to_string(), + outcome, + not_before: chrono::Utc::now(), + priority: 0, + effective_priority: 0, + retry_backoff: 1.0, + attempted: 0, + target: Default::default(), + }; + let _ = tx.send(event).await; + } } diff --git a/workshop-baker/tests/bake_pipeline_integration.rs b/workshop-baker/tests/bake_pipeline_integration.rs deleted file mode 100644 index b51f9d7..0000000 --- a/workshop-baker/tests/bake_pipeline_integration.rs +++ /dev/null @@ -1,84 +0,0 @@ -use workshop_baker::bake::parser::parse_script; - -#[test] -fn test_full_pipeline_parse() { - let script = r#" -# @pipeline -func1() { echo 1; } - -# @pipeline -func2() { echo 2; } -"#; - let parsed = parse_script(script).unwrap(); - assert_eq!(parsed.pipeline_functions.len(), 2); - assert_eq!(parsed.pipeline_functions[0].name, "func1"); - assert_eq!(parsed.pipeline_functions[1].name, "func2"); -} - -#[test] -fn test_pipeline_with_after_decorator() { - let script = r#" -# @pipeline -build() { echo building; } - -# @pipeline -# @after(build) -test() { echo testing; } -"#; - let parsed = parse_script(script).unwrap(); - assert_eq!(parsed.pipeline_functions.len(), 2); - assert_eq!(parsed.pipeline_functions[0].name, "build"); - assert_eq!(parsed.pipeline_functions[1].name, "test"); - assert_eq!(parsed.pipeline_functions[1].decorators.len(), 2); -} - -#[test] -fn test_pipeline_with_mixed_decorators() { - let script = r#" -# @pipeline -# @timeout(60) -step1() { echo step1; } - -# @pipeline -# @retry(3, 10) -# @after(step1) -step2() { echo step2; } -"#; - let parsed = parse_script(script).unwrap(); - assert_eq!(parsed.pipeline_functions.len(), 2); - assert_eq!(parsed.pipeline_functions[0].name, "step1"); - assert_eq!(parsed.pipeline_functions[1].name, "step2"); - assert_eq!(parsed.pipeline_functions[0].decorators.len(), 2); - assert_eq!(parsed.pipeline_functions[1].decorators.len(), 3); -} - -#[test] -fn test_pipeline_remaining_code_excludes_pipeline_functions() { - let script = r#"#!/bin/bash -# comment -helper() { echo helper; } - -# @pipeline -build() { echo build; } -"#; - let parsed = parse_script(script).unwrap(); - assert_eq!(parsed.pipeline_functions.len(), 1); - assert_eq!(parsed.pipeline_functions[0].name, "build"); - assert!(parsed.remaining_code.contains("helper")); - assert!(!parsed.remaining_code.contains("# @pipeline")); -} - -#[test] -fn test_pipeline_multiline_function() { - let script = r#" -# @pipeline -deploy() { - echo "deploying" - echo "done" -} -"#; - let parsed = parse_script(script).unwrap(); - assert_eq!(parsed.pipeline_functions.len(), 1); - assert_eq!(parsed.pipeline_functions[0].name, "deploy"); - assert!(parsed.pipeline_functions[0].body.contains("deploying")); -} diff --git a/workshop-baker/tests/finalize_hook_integration.rs b/workshop-baker/tests/finalize_hook_integration.rs deleted file mode 100644 index eb614e4..0000000 --- a/workshop-baker/tests/finalize_hook_integration.rs +++ /dev/null @@ -1,184 +0,0 @@ -//! Integration tests for finalize hook execution -//! -//! These tests verify that shell hooks and plugin hooks execute correctly -//! through the finalize stage hook executor. - -use std::collections::HashMap; -use std::time::Duration; -use workshop_baker::ExecutionContext; -use workshop_baker::finalize::plugin::PluginMap; -use workshop_baker::finalize::stage::hook::hook; -use workshop_engine::{CustomCommand, ExecutionEvent}; - -fn create_test_context() -> ExecutionContext { - ExecutionContext { - task_id: "test-hook-1".to_string(), - pipeline_name: "test-pipeline".to_string(), - username: "testuser".to_string(), - working_dir: std::env::temp_dir(), - env_vars: HashMap::new(), - timeout: Some(Duration::from_secs(30)), - shell: "bash".to_string(), - dry_run: false, - privileged: false, - standalone: false, - } -} - -fn create_test_event_channel() -> ( - Option>, - tokio::sync::mpsc::Receiver, -) { - let (tx, rx) = tokio::sync::mpsc::channel(64); - (Some(tx), rx) -} - -#[tokio::test] -async fn test_shell_hook_success() { - let ctx = create_test_context(); - let (event_tx, _event_rx) = create_test_event_channel(); - let plugins = PluginMap::new(); - - let cmd = CustomCommand { - name: "echo-test".to_string(), - command: "echo 'hook-ok'".to_string(), - environment: None, - working_dir: None, - timeout_ms: 5000, - argument: serde_yaml::Value::Null, - }; - - let result = hook(Some(&vec![cmd]), &ctx, event_tx, &plugins).await; - assert!(result.is_ok(), "Shell hook should succeed: {:?}", result); -} - -#[tokio::test] -async fn test_shell_hook_failure_propagates() { - let ctx = create_test_context(); - let (event_tx, _event_rx) = create_test_event_channel(); - let plugins = PluginMap::new(); - - let cmd = CustomCommand { - name: "false-cmd".to_string(), - command: "false".to_string(), - environment: None, - working_dir: None, - timeout_ms: 5000, - argument: serde_yaml::Value::Null, - }; - - let result = hook(Some(&vec![cmd]), &ctx, event_tx, &plugins).await; - assert!(result.is_err(), "Shell hook failure should propagate"); -} - -#[tokio::test] -async fn test_hook_timeout_kills_long_command() { - let ctx = create_test_context(); - let (event_tx, _event_rx) = create_test_event_channel(); - let plugins = PluginMap::new(); - - let cmd = CustomCommand { - name: "sleep-forever".to_string(), - command: "sleep 10".to_string(), - environment: None, - working_dir: None, - timeout_ms: 500, // 0.5 seconds - argument: serde_yaml::Value::Null, - }; - - let result = hook(Some(&vec![cmd]), &ctx, event_tx, &plugins).await; - assert!(result.is_err(), "Hook should fail due to timeout"); -} - -#[tokio::test] -async fn test_hook_working_dir_applied() { - let ctx = create_test_context(); - let (event_tx, _event_rx) = create_test_event_channel(); - let plugins = PluginMap::new(); - - let cmd = CustomCommand { - name: "pwd-check".to_string(), - command: "pwd".to_string(), - environment: None, - working_dir: Some("/tmp".to_string()), - timeout_ms: 5000, - argument: serde_yaml::Value::Null, - }; - - // We verify by checking the hook returns Ok — the working_dir is passed to - // Engine::execute_command_with_delta. Full stdout verification would require - // mocking the Engine or using a more intrusive test setup. - let result = hook(Some(&vec![cmd]), &ctx, event_tx, &plugins).await; - assert!(result.is_ok(), "Hook with working_dir should succeed"); -} - -#[tokio::test] -async fn test_no_hooks_returns_ok() { - let ctx = create_test_context(); - let (event_tx, _event_rx) = create_test_event_channel(); - let plugins = PluginMap::new(); - - let result = hook(None, &ctx, event_tx, &plugins).await; - assert!(result.is_ok(), "No hooks should return Ok immediately"); -} - -#[tokio::test] -async fn test_multiple_hooks_execute_sequentially() { - let ctx = create_test_context(); - let (event_tx, _event_rx) = create_test_event_channel(); - let plugins = PluginMap::new(); - - let cmds = vec![ - CustomCommand { - name: "hook-1".to_string(), - command: "true".to_string(), - environment: None, - working_dir: None, - timeout_ms: 5000, - argument: serde_yaml::Value::Null, - }, - CustomCommand { - name: "hook-2".to_string(), - command: "true".to_string(), - environment: None, - working_dir: None, - timeout_ms: 5000, - argument: serde_yaml::Value::Null, - }, - ]; - - let result = hook(Some(&cmds), &ctx, event_tx, &plugins).await; - assert!( - result.is_ok(), - "Multiple hooks should execute sequentially and succeed" - ); -} - -#[tokio::test] -async fn test_first_failure_stops_remaining_hooks() { - let ctx = create_test_context(); - let (event_tx, _event_rx) = create_test_event_channel(); - let plugins = PluginMap::new(); - - let cmds = vec![ - CustomCommand { - name: "failing-hook".to_string(), - command: "false".to_string(), - environment: None, - working_dir: None, - timeout_ms: 5000, - argument: serde_yaml::Value::Null, - }, - CustomCommand { - name: "never-reached".to_string(), - command: "true".to_string(), - environment: None, - working_dir: None, - timeout_ms: 5000, - argument: serde_yaml::Value::Null, - }, - ]; - - let result = hook(Some(&cmds), &ctx, event_tx, &plugins).await; - assert!(result.is_err(), "First failing hook should stop execution"); -} diff --git a/workshop-baker/tests/finalize_pipeline_integration.rs b/workshop-baker/tests/finalize_pipeline_integration.rs deleted file mode 100644 index be8ed59..0000000 --- a/workshop-baker/tests/finalize_pipeline_integration.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Integration tests for the full finalize pipeline -//! -//! These tests verify the complete finalize workflow from YAML parsing -//! through hook execution, using real files and the public `finalize()` entry point. - -use std::collections::HashMap; -use std::time::Duration; -use workshop_baker::ExecutionContext; -use workshop_baker::cli::Cli; -use workshop_baker::finalize::finalize; -use workshop_engine::ExecutionEvent; - -fn create_test_context() -> ExecutionContext { - ExecutionContext { - task_id: "test-finalize-1".to_string(), - pipeline_name: "test-pipeline".to_string(), - username: "testuser".to_string(), - working_dir: std::env::temp_dir(), - env_vars: HashMap::new(), - timeout: Some(Duration::from_secs(30)), - shell: "bash".to_string(), - dry_run: false, - privileged: false, - standalone: false, - } -} - -fn create_test_cli() -> Cli { - Cli { - username: "testuser".to_string(), - pipeline: "test-pipeline".to_string(), - build_id: "test-build-1".to_string(), - dry_run: false, - command: None, - } -} - -fn create_test_event_channel() -> ( - Option>, - tokio::sync::mpsc::Receiver, -) { - let (tx, rx) = tokio::sync::mpsc::channel(64); - (Some(tx), rx) -} - -#[tokio::test] -async fn test_full_finalize_with_early_late_hooks() { - let temp_dir = std::env::temp_dir(); - let finalize_path = temp_dir.join("test_finalize.yml"); - - let yaml_content = r#" -version: "0.0.1" -hooks: - early: - - name: "early-echo" - command: "echo early-hook-ok" - timeout: 5 - late: - - name: "late-echo" - command: "echo late-hook-ok" - timeout: 5 -"#; - - std::fs::write(&finalize_path, yaml_content).expect("Failed to write test finalize.yml"); - - let cli = create_test_cli(); - let mut ctx = create_test_context(); - let (event_tx, mut event_rx) = create_test_event_channel(); - - // Spawn an event consumer so the channel does not block - let consumer_handle = tokio::spawn(async move { - while let Some(_event) = event_rx.recv().await { - // Events are consumed but not validated in this test - } - }); - - let result = finalize(&finalize_path, &cli, &mut ctx, event_tx).await; - - let _ = consumer_handle.await; - std::fs::remove_file(&finalize_path).ok(); - - assert!( - result.is_ok(), - "Full finalize pipeline should succeed: {:?}", - result - ); -} - -#[tokio::test] -async fn test_full_finalize_with_empty_hooks() { - let temp_dir = std::env::temp_dir(); - let finalize_path = temp_dir.join("test_finalize_empty.yml"); - - let yaml_content = r#" -version: "0.0.1" -"#; - - std::fs::write(&finalize_path, yaml_content).expect("Failed to write test finalize.yml"); - - let cli = create_test_cli(); - let mut ctx = create_test_context(); - let (event_tx, mut event_rx) = create_test_event_channel(); - - let consumer_handle = - tokio::spawn(async move { while let Some(_event) = event_rx.recv().await {} }); - - let result = finalize(&finalize_path, &cli, &mut ctx, event_tx).await; - - let _ = consumer_handle.await; - std::fs::remove_file(&finalize_path).ok(); - - assert!( - result.is_ok(), - "Finalize with empty hooks should succeed: {:?}", - result - ); -} - -#[tokio::test] -async fn test_full_finalize_with_failing_early_hook() { - let temp_dir = std::env::temp_dir(); - let finalize_path = temp_dir.join("test_finalize_fail.yml"); - - let yaml_content = r#" -version: "0.0.1" -hooks: - early: - - name: "failing-hook" - command: "false" - timeout: 5 - late: - - name: "never-reached" - command: "echo should-not-run" - timeout: 5 -"#; - - std::fs::write(&finalize_path, yaml_content).expect("Failed to write test finalize.yml"); - - let cli = create_test_cli(); - let mut ctx = create_test_context(); - let (event_tx, mut event_rx) = create_test_event_channel(); - - let consumer_handle = - tokio::spawn(async move { while let Some(_event) = event_rx.recv().await {} }); - - let result = finalize(&finalize_path, &cli, &mut ctx, event_tx).await; - - let _ = consumer_handle.await; - std::fs::remove_file(&finalize_path).ok(); - - // With the notification subsystem, prior stage failures cause finalize to - // send a failure notification and then return Ok (skipping artifacts/latehook) - assert!( - result.is_ok(), - "Finalize with failing early hook should return Ok after sending failure notification" - ); -} diff --git a/workshop-baker/tests/prebake_config_integration.rs b/workshop-baker/tests/prebake_config_integration.rs deleted file mode 100644 index 0344c71..0000000 --- a/workshop-baker/tests/prebake_config_integration.rs +++ /dev/null @@ -1,79 +0,0 @@ -use workshop_baker::prebake::parse; -use workshop_baker::prebake::types::builderconfig::BuilderType; - -#[test] -fn test_prebake_config_minimal_yaml() { - let yaml = r#" -version: "1.0" -environment: - builder: docker -"#; - let config = parse(yaml).unwrap(); - assert_eq!(config.version, "1.0"); - assert_eq!(config.environment.builder, BuilderType::Docker); -} - -#[test] -fn test_prebake_config_with_builder_type_baremetal() { - let yaml = r#" -version: "1.0" -environment: - builder: baremetal -"#; - let config = parse(yaml).unwrap(); - assert_eq!(config.environment.builder, BuilderType::Baremetal); -} - -#[test] -fn test_prebake_config_with_envvars() { - let yaml = r#" -version: "1.0" -environment: - builder: baremetal -envvars: - prebake: - RUST_LOG: debug -"#; - let config = parse(yaml).unwrap(); - assert_eq!(config.environment.builder, BuilderType::Baremetal); - let prebake_vars = config.envvars.unwrap().prebake.unwrap(); - assert_eq!(prebake_vars.get("RUST_LOG").unwrap(), "debug"); -} - -#[test] -fn test_prebake_config_with_bootstrap_workspace() { - let yaml = r#" -version: "1.0" -environment: - builder: docker -bootstrap: - workspace: - path: "/workspace" -"#; - let config = parse(yaml).unwrap(); - let bootstrap = config.bootstrap.unwrap(); - let workspace = bootstrap.workspace.unwrap(); - assert_eq!(workspace.path, "/workspace"); -} - -#[test] -fn test_prebake_config_validation_passes() { - let yaml = r#" -version: "1.0" -environment: - builder: docker -"#; - let config = parse(yaml).unwrap(); - assert!(config.validate().is_ok()); -} - -#[test] -fn test_prebake_config_validation_fails_bad_version() { - let yaml = r#" -version: "99.0" -environment: - builder: docker -"#; - let config = parse(yaml).unwrap(); - assert!(config.validate().is_err()); -} diff --git a/workshop-baker/tests/types_config_integration.rs b/workshop-baker/tests/types_config_integration.rs deleted file mode 100644 index d0b2b29..0000000 --- a/workshop-baker/tests/types_config_integration.rs +++ /dev/null @@ -1,88 +0,0 @@ -use workshop_baker::finalize::types::compression::CompressionMethod; -use workshop_baker::prebake::types::architecture::Architecture; -use workshop_baker::prebake::types::builderconfig::BuilderConfig; -use workshop_baker::prebake::types::cache::{CacheDirectory, CacheMode, CacheStrategyType}; -use workshop_engine::{CustomCommand, RepologyEndpoint}; - -#[test] -fn test_architecture_from_str() { - let arch: Architecture = "x86_64".parse().unwrap(); - assert_eq!(arch, Architecture::Amd64); - let arch: Architecture = "arm64".parse().unwrap(); - assert_eq!(arch, Architecture::Aarch64); - let arch: Architecture = "riscv64".parse().unwrap(); - assert_eq!(arch, Architecture::Riscv64); -} - -#[test] -fn test_architecture_normalized() { - assert_eq!(Architecture::Amd64.normalized(), Architecture::X86_64); - assert_eq!(Architecture::Aarch64.normalized(), Architecture::Arm64); -} - -#[test] -fn test_builder_config_docker_yaml() { - let yaml = r#" -image: "rust:latest" -build_args: - TARGET: x86_64 -"#; - let config: BuilderConfig = serde_yaml::from_str(yaml).unwrap(); - match config { - BuilderConfig::Docker(docker) => { - assert_eq!(docker.image, Some("rust:latest".to_string())); - } - _ => panic!("Expected Docker config"), - } -} - -#[test] -fn test_cache_directory_yaml_roundtrip() { - let cache = CacheDirectory { - path: "/tmp/cache".to_string(), - strategy: CacheStrategyType::Readonly, - mode: CacheMode::Gzip, - }; - let yaml = serde_yaml::to_string(&cache).unwrap(); - let deserialized: CacheDirectory = serde_yaml::from_str(&yaml).unwrap(); - assert_eq!(cache.path, deserialized.path); - assert_eq!(cache.strategy, deserialized.strategy); - assert_eq!(cache.mode, deserialized.mode); -} - -#[test] -fn test_repology_endpoint_yaml() { - let ep: RepologyEndpoint = serde_yaml::from_str("disabled").unwrap(); - assert_eq!(ep, RepologyEndpoint::Disabled); - let ep: RepologyEndpoint = serde_yaml::from_str("local").unwrap(); - assert_eq!(ep, RepologyEndpoint::Local); -} - -#[test] -fn test_compression_method_yaml() { - let cm: CompressionMethod = serde_yaml::from_str("gzip").unwrap(); - assert!(matches!(cm, CompressionMethod::Gzip)); - let cm: CompressionMethod = serde_yaml::from_str("zstd").unwrap(); - assert!(matches!(cm, CompressionMethod::Zstd)); -} - -#[test] -fn test_custom_command_yaml() { - let yaml = r#" -name: build -timeout: 300 -command: cargo build -"#; - let cmd: CustomCommand = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(cmd.name, "build"); - assert_eq!(cmd.timeout_ms, 300_000); - assert_eq!(cmd.command, "cargo build"); -} - -#[test] -fn test_custom_command_default_timeout() { - let yaml = "command: echo hello"; - let cmd: CustomCommand = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(cmd.name, "(unnamed)"); - assert_eq!(cmd.timeout_ms, 300_000); -}