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:
+1
-1
@@ -9,7 +9,7 @@ archived
|
||||
*.bak
|
||||
*.clean
|
||||
session*
|
||||
llm.env
|
||||
.secret
|
||||
|
||||
# System test artifacts
|
||||
tests/results/
|
||||
|
||||
@@ -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."
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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']}"
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
#!/bin/bash
|
||||
[ -f /dev/shm/bakeexport.{{ name }} ] && . /dev/shm/bakeexport.{{ name }} || true
|
||||
{{ condition }}
|
||||
{{ main }}
|
||||
{{ name }}
|
||||
{{ export }}
|
||||
Vendored
-25
@@ -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
|
||||
}
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
echo "Hello from simple bake script"
|
||||
echo "Task ID: $HBW_TASKID"
|
||||
echo "Pipeline: $HBW_PIPELINE"
|
||||
exit 0
|
||||
Vendored
-22
@@ -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
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
echo "EARLY_HOOK_EXECUTED"
|
||||
echo "Working directory: $(pwd)"
|
||||
echo "User: $(whoami)"
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
echo "LATE_HOOK_EXECUTED"
|
||||
echo "Working directory: $(pwd)"
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
version: "0.0.1"
|
||||
plugin: {}
|
||||
notification: {}
|
||||
artifact: []
|
||||
cleanup:
|
||||
policy: "auto"
|
||||
-13
@@ -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'"
|
||||
Vendored
-6
@@ -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"]
|
||||
Vendored
-6
@@ -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"]
|
||||
Vendored
-6
@@ -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"]
|
||||
Vendored
-2
@@ -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
@@ -1,8 +0,0 @@
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
workspace:
|
||||
path: "/home/vulcan/workspace"
|
||||
fallback: true
|
||||
Vendored
-15
@@ -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
@@ -1,3 +0,0 @@
|
||||
version: "0.5"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
workspace:
|
||||
path: "/home/vulcan/workspace"
|
||||
fallback: true
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,10 +0,0 @@
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
workspace:
|
||||
path: "/home/vulcan/workspace"
|
||||
fallback: true
|
||||
security:
|
||||
drop_after: "Bootstrap"
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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="",
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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']}"
|
||||
@@ -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,3 +1,4 @@
|
||||
temp
|
||||
examples
|
||||
quicktest.sh
|
||||
_env.sh
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(¬ify_path, &mut notify_ctx.clone(), notify_etx, to_rx, retry_tx, debug).await
|
||||
});
|
||||
|
||||
let nevent_tx = to_tx.clone();
|
||||
let mut queue = NotificationQueue::new(to_tx, retry_rx);
|
||||
|
||||
let pipe_result: anyhow::Result<()> = async {
|
||||
prebake(&prebake_path, &cli, None, &mut ctx, event_tx.clone(), Some(nevent_tx)).await?;
|
||||
let bake_base_path = or_workshop(&cli.bake_base, "bake_base.sh");
|
||||
let pb_path = or_workshop(&cli.prebake, "prebake.yml");
|
||||
bake(&bake_path, &bake_base_path, &pb_path, &cli, &mut ctx, event_tx.clone()).await?;
|
||||
finalize(&finalize_path, &cli, &mut ctx, event_tx.clone()).await?;
|
||||
Ok(())
|
||||
}.await;
|
||||
|
||||
if let Err(e) = pipe_result {
|
||||
log::error!("Pipeline stage failed: {}", e);
|
||||
return Err(CliError::General(e));
|
||||
}
|
||||
|
||||
log::info!("Pipeline complete. Draining notifications...");
|
||||
if let Err(e) = queue.drain().await {
|
||||
log::error!("Notification queue error: {}", e);
|
||||
}
|
||||
let _ = notify_handle.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -14,85 +88,45 @@ async fn main() {
|
||||
if cli.dry_run {
|
||||
log::warn!("Dry run enabled, no changes will be made.");
|
||||
}
|
||||
|
||||
let pipeline = cli.pipeline.clone();
|
||||
let build_id = cli.build_id.clone();
|
||||
|
||||
match &cli.command {
|
||||
Some(Commands::Bare { command }) => {
|
||||
|
||||
log::info!("Running in bare mode.");
|
||||
|
||||
let (stage, privileged) = match command {
|
||||
BareCommands::Prebake { .. } => ("init", true),
|
||||
BareCommands::Bake { .. } => ("bake", false),
|
||||
BareCommands::Finalize { .. } => ("finalize", false),
|
||||
};
|
||||
let mut ctx = ExecutionContext {
|
||||
standalone: true,
|
||||
task_id: format!("{}-{}-{}", cli.pipeline, cli.build_id, stage),
|
||||
pipeline_name: cli.pipeline.clone(),
|
||||
task_id: format!("{}-{}-bare", pipeline, build_id),
|
||||
pipeline_name: pipeline.clone(),
|
||||
username: cli.username.clone(),
|
||||
dry_run: cli.dry_run,
|
||||
privileged,
|
||||
privileged: false,
|
||||
..Default::default()
|
||||
};
|
||||
let (tx, event_rx) = tokio::sync::mpsc::channel(256);
|
||||
let event_tx: EventSender = Some(tx);
|
||||
let handle = tokio::spawn(workshop_baker::prebake::event::event_receiver(
|
||||
event_rx,
|
||||
cli.pipeline.clone(),
|
||||
cli.build_id.clone(),
|
||||
let event_handle = tokio::spawn(workshop_baker::prebake::event::event_receiver(
|
||||
event_rx, pipeline.clone(), build_id.clone(),
|
||||
));
|
||||
|
||||
let result: anyhow::Result<()> = match command {
|
||||
BareCommands::Prebake { prebake: config_path } => {
|
||||
prebake(config_path, &cli, None, &mut ctx, event_tx).await
|
||||
}
|
||||
BareCommands::Bake {
|
||||
script,
|
||||
bake_base,
|
||||
prebake: prebake_path,
|
||||
} => bake(script, bake_base, prebake_path, &cli, &mut ctx, event_tx)
|
||||
.await
|
||||
.map_err(|e| e.into()),
|
||||
BareCommands::Finalize { finalize: config_str } => {
|
||||
finalize(Path::new(&config_str), &cli, &mut ctx, event_tx)
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
};
|
||||
let result = run_bare(&cli, command, &mut ctx, event_tx, &pipeline, &build_id).await;
|
||||
|
||||
drop(ctx);
|
||||
let _ = handle.await;
|
||||
dbg!(&result);
|
||||
if let Err(ref e) = result {
|
||||
let code = match command {
|
||||
BareCommands::Prebake { .. } => e
|
||||
.downcast_ref::<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¬ify_plugins, ¬ification_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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user