refactor(baker): Restructure CLI, drop legacy tests

- Remove Python-based test framework, Dockerfiles, and runner scripts
- Add bare subcommand for standalone pipeline stage execution
- Implement unified worker mode and error module
- Refactor NotificationConfig to support YAML string keys
- Add dummy internal plugin and clean up mail template
This commit is contained in:
Catty Steve
2026-05-19 16:19:28 +08:00
parent f737b6a24d
commit c97eafab29
64 changed files with 378 additions and 3355 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ archived
*.bak
*.clean
session*
llm.env
.secret
# System test artifacts
tests/results/
-217
View File
@@ -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:<name> | pm:<name>
TEST_DIR=""
TEST_IMAGE=""
PYTEST_ARGS=()
NETWORK=0
BAKER_LOG=1
# ── parse args ──────────────────────────────────────────────────────
show_help() {
cat << 'HELP'
Usage: ./runner.sh [OPTIONS]
Categories (-c):
baker Baker system tests [default]
upm:rust Rust UPM tests
pm:pacman Pacman PM engine tests
pm:apt APT PM engine tests
pm:dnf DNF PM engine tests
pm:apk APK PM engine tests
Options:
-c, --category CAT Test category (see above)
--network Allow network-dependent tests (toolchain/crate downloads)
-k FILTER pytest -k expression
--timeout SECONDS Per-test timeout (default 120)
--log / --no-log Stream baker output to terminal [default: --log]
-h, --help This message
HELP
exit 0
}
while [[ $# -gt 0 ]]; do
case "$1" in
-c|--category)
CATEGORY="$2"; shift 2 ;;
--network)
NETWORK=1; shift ;;
-k)
PYTEST_ARGS+=(-k "$2"); shift 2 ;;
--timeout)
PYTEST_ARGS+=(--timeout "$2"); shift 2 ;;
--log) BAKER_LOG=1; shift ;;
--no-log) BAKER_LOG=0; shift ;;
manual) MODE="manual"; shift ;;
auto) MODE="auto"; shift ;;
-h|--help)
show_help ;;
--)
shift; PYTEST_ARGS+=("$@"); break ;;
*)
PYTEST_ARGS+=("$1"); shift ;;
esac
done
# ── resolve category → test dir + image ─────────────────────────────
case "$CATEGORY" in
baker)
TEST_DIR="tests/baker"
TEST_IMAGE="hbw-system-test:latest"
;;
upm:rust)
TEST_DIR="tests/upm"
TEST_IMAGE="hbw-system-test:latest"
;;
pm:pacman|pm:arch)
TEST_DIR="tests/engine"
TEST_IMAGE="hbw-test-pm:test-arch"
;;
pm:apt)
TEST_DIR="tests/engine"
TEST_IMAGE="hbw-test-pm:test-apt"
;;
pm:dnf)
TEST_DIR="tests/engine"
TEST_IMAGE="hbw-test-pm:test-dnf"
;;
pm:apk)
TEST_DIR="tests/engine"
TEST_IMAGE="hbw-test-pm:test-apk"
;;
*)
echo "Unknown category: $CATEGORY" >&2
echo "Run './runner.sh --help' for usage." >&2
exit 1
;;
esac
# ── env vars ──────────────────────────────────────────────────────
DOCKER_ENV=()
DOCKER_ENV+=(-e RUST_LOG=${RUST_LOG:-info})
DOCKER_ENV+=(-e BAKER_LOG=${BAKER_LOG:-1})
# Load llm.env if present (for LLM-based test evaluation)
if [ -f "$SCRIPT_DIR/llm.env" ]; then
while IFS='=' read -r key value; do
[ -z "$key" ] && continue
[[ "$key" =~ ^# ]] && continue
DOCKER_ENV+=(-e "$key=$value")
done < "$SCRIPT_DIR/llm.env"
fi
if [ "$NETWORK" -eq 1 ]; then
DOCKER_ENV+=(-e RUST_UPM_NETWORK=1)
fi
# ── cleanup ─────────────────────────────────────────────────────────
cleanup() {
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
}
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."
-32
View File
@@ -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"]
-105
View File
@@ -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")
-62
View File
@@ -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
-202
View File
@@ -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']}"
-180
View File
@@ -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)
-102
View File
@@ -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)
-36
View File
@@ -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
-6
View File
@@ -1,6 +0,0 @@
#!/bin/bash
[ -f /dev/shm/bakeexport.{{ name }} ] && . /dev/shm/bakeexport.{{ name }} || true
{{ condition }}
{{ main }}
{{ name }}
{{ export }}
-25
View File
@@ -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
}
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
echo "Hello from simple bake script"
echo "Task ID: $HBW_TASKID"
echo "Pipeline: $HBW_PIPELINE"
exit 0
-22
View File
@@ -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
}
-4
View File
@@ -1,4 +0,0 @@
#!/bin/bash
echo "EARLY_HOOK_EXECUTED"
echo "Working directory: $(pwd)"
echo "User: $(whoami)"
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
echo "LATE_HOOK_EXECUTED"
echo "Working directory: $(pwd)"
-6
View File
@@ -1,6 +0,0 @@
version: "0.0.1"
plugin: {}
notification: {}
artifact: []
cleanup:
policy: "auto"
-13
View File
@@ -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'"
-6
View File
@@ -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"]
-6
View File
@@ -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"]
-6
View File
@@ -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"]
-2
View File
@@ -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
-8
View File
@@ -1,8 +0,0 @@
version: "1.0"
environment:
builder: "baremetal"
bootstrap:
user: "vulcan"
workspace:
path: "/home/vulcan/workspace"
fallback: true
-15
View File
@@ -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'"
-3
View File
@@ -1,3 +0,0 @@
version: "0.5"
environment:
builder: "baremetal"
-8
View File
@@ -1,8 +0,0 @@
version: "1.0"
environment:
builder: "baremetal"
bootstrap:
user: "vulcan"
workspace:
path: "/home/vulcan/workspace"
fallback: true
-12
View File
@@ -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
-16
View File
@@ -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"
-13
View File
@@ -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"
-17
View File
@@ -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"
-15
View File
@@ -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"
-10
View File
@@ -1,10 +0,0 @@
version: "1.0"
environment:
builder: "baremetal"
bootstrap:
user: "vulcan"
workspace:
path: "/home/vulcan/workspace"
fallback: true
security:
drop_after: "Bootstrap"
-12
View File
@@ -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"]
-54
View File
@@ -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()
-187
View File
@@ -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
-149
View File
@@ -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="",
)
-45
View File
@@ -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",
]
-53
View File
@@ -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)
-42
View File
@@ -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 <image>``.
"""
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
-44
View File
@@ -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
-41
View File
@@ -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
-6
View File
@@ -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
View File
-150
View File
@@ -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"
-163
View File
@@ -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']}"
-275
View File
@@ -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"))
+1
View File
@@ -1,3 +1,4 @@
temp
examples
quicktest.sh
_env.sh
+49
View File
@@ -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<PathBuf>, 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(())
}
+30
View File
@@ -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<anyhow::Error> 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,
}
}
}
+27 -2
View File
@@ -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<String, NotificationTemplateDef>,
#[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<u32, HashMap<String, NotificationMethod>>,
}
/// Serde helper: YAML map keys are always strings, convert to u32.
impl<'de> serde::Deserialize<'de> for NotificationConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where D: serde::Deserializer<'de>
{
#[derive(serde::Deserialize)]
struct Helper {
#[serde(default)]
templates: HashMap<String, NotificationTemplateDef>,
#[serde(flatten)]
groups_raw: HashMap<String, HashMap<String, NotificationMethod>>,
}
let h = Helper::deserialize(deserializer)?;
let groups: HashMap<u32, _> = h.groups_raw.into_iter()
.map(|(k, v)| {
k.parse::<u32>().map(|n| (n, v))
.map_err(|_| serde::de::Error::custom(format!("priority key must be integer, got {:?}", k)))
})
.collect::<Result<_, _>>()?;
Ok(NotificationConfig { templates: h.templates, groups })
}
}
/// Stage-triggered notification configuration
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct OnFinishOf {
@@ -27,5 +27,9 @@ pub fn get_internal_plugin() -> Vec<InternalPlugin> {
name: "webhook",
entry: Box::new(webhook::Webhook),
},
InternalPlugin {
name: "dummy",
entry: Box::new(dummy::Dummy),
},
]
}
@@ -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(())
}
}
@@ -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
}
}
@@ -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<String>,
/// 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<String>,
/// Email body content (required for custom schema).
pub body: Option<String>,
/// 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<String>> {
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#"<!DOCTYPE html>
<html>
<body style="font-family: Arial, sans-serif;">
<h2 style="color: {{ build.status_color }};">
Build {{ build.status | title }}
</h2>
<table>
<tr><td><strong>Pipeline:</strong></td><td>{{ pipeline.name }}</td></tr>
<tr><td><strong>Build ID:</strong></td><td>{{ build.id }}</td></tr>
<tr><td><strong>Status:</strong></td><td>{{ build.status }}</td></tr>
<tr><td><strong>Duration:</strong></td><td>{{ build.duration }}</td></tr>
<tr><td><strong>Author:</strong></td><td>{{ commit.author }}</td></tr>
<tr><td><strong>Message:</strong></td><td>{{ commit.message }}</td></tr>
</table>
<p>
<a href="{{ build.url }}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
View Build Details
</a>
</p>
</body>
</html>"#;
/// 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));
}
}
@@ -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<String, serde_json::Value> {
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
}
+23 -18
View File
@@ -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<PathBuf>,
/// Override bake script path
#[arg(long)]
pub bake: Option<PathBuf>,
/// Override bake_base.sh path
#[arg(long)]
pub bake_base: Option<PathBuf>,
/// Override finalize config path
#[arg(long)]
pub finalize: Option<PathBuf>,
/// Debug features (comma-separated). "notify" enables notification debug logging.
#[arg(long, env = "HBW_DEBUG")]
pub debug: Option<String>,
#[command(subcommand)]
pub command: Option<Commands>,
}
@@ -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 {},
}
}
+98 -64
View File
@@ -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<PathBuf>, 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(&notify_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::<workshop_baker::prebake::error::PrebakeError>()
.map(|pe| pe.exit_code())
.unwrap_or(1),
BareCommands::Bake { .. } => e
.downcast_ref::<workshop_baker::bake::error::BakeError>()
.map(|be| be.exit_code())
.unwrap_or(1),
BareCommands::Finalize { .. } => e
.downcast_ref::<workshop_baker::finalize::FinalizeError>()
.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)
}
}
}
+54 -8
View File
@@ -71,15 +71,34 @@ pub async fn notify(
_event_tx: EventSender,
mut rx: NotificationEventReceiver,
retry_tx: NotificationEventSender,
debug: Option<String>,
) -> 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(&notify_plugins, &notification_rules, &events, &finalize.notification.templates, ctx).await;
let mut na: Vec<String> = 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<Method, Vec<NotificationRule>> = HashMap::new();
for (method_name, method) in group {
let mut method_rules: Vec<NotificationRule> = Vec::new();
if method.policy.is_empty() {
let trigger: Vec<ParsedTrigger> = method
.trigger
+4
View File
@@ -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))
+34
View File
@@ -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<PrebakeStage>,
ctx: &mut ExecutionContext,
event_tx: EventSender,
nevent_tx: Option<NotificationEventSender>,
) -> 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<NotificationEventSender>,
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;
}
}
@@ -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"));
}
@@ -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::Sender<ExecutionEvent>>,
tokio::sync::mpsc::Receiver<ExecutionEvent>,
) {
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");
}
@@ -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::Sender<ExecutionEvent>>,
tokio::sync::mpsc::Receiver<ExecutionEvent>,
) {
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"
);
}
@@ -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());
}
@@ -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);
}