Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdc00f0635 | |||
| 26809df720 | |||
| 3629c33fc4 | |||
| 6d9ddbca56 | |||
| 9ff9073fce | |||
| df5026cdbe | |||
| d1ad08ef3a | |||
| cf2968e720 | |||
| 28abc5d207 | |||
| 6e7b96cd66 | |||
| 3e80825d58 | |||
| c97eafab29 | |||
| f737b6a24d | |||
| 681b7d1333 | |||
| f07588a716 | |||
| b0530fb858 | |||
| fd18b547e5 | |||
| cf317b55c6 | |||
| 20e2ab4224 | |||
| 50b42e654c | |||
| a64e47d772 | |||
| 8fd807f174 | |||
| e8ce2eec9e |
+20
-14
@@ -5,27 +5,33 @@ on:
|
||||
branches: [ master, main ]
|
||||
paths:
|
||||
- 'workshop-baker/**'
|
||||
- 'workshop-engine/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
pull_request:
|
||||
branches: [ master, main ]
|
||||
paths:
|
||||
- 'workshop-baker/**'
|
||||
- 'workshop-engine/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
check-and-lint:
|
||||
name: Check & Lint
|
||||
ci:
|
||||
name: ${{ matrix.crate }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
crate: [workshop-baker, workshop-engine]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./workshop-baker
|
||||
working-directory: ./${{ matrix.crate }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -38,19 +44,19 @@ jobs:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
${{ matrix.crate }}/target
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.crate }}-${{ hashFiles(format('{0}/Cargo.lock', matrix.crate)) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
${{ runner.os }}-cargo-${{ matrix.crate }}-
|
||||
|
||||
- name: Check compilation
|
||||
run: cargo check --all-features
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --all-features
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt -- --check
|
||||
|
||||
- name: Check code (cargo check)
|
||||
run: cargo check --all-features
|
||||
|
||||
- name: Run Clippy lints
|
||||
run: cargo clippy --all-features -- -D warnings
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --all-features
|
||||
|
||||
+3
-1
@@ -9,7 +9,7 @@ archived
|
||||
*.bak
|
||||
*.clean
|
||||
session*
|
||||
llm.env
|
||||
.secret
|
||||
|
||||
# System test artifacts
|
||||
tests/results/
|
||||
@@ -18,3 +18,5 @@ tests/**/__pycache__/
|
||||
tests/.pytest_cache/
|
||||
tests/llm/.cache/
|
||||
*.pyc
|
||||
.staging
|
||||
.pytest_cache
|
||||
|
||||
@@ -1,107 +1,127 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2026-04-22
|
||||
**Commit:** 7b3b71a
|
||||
**Branch:** master
|
||||
**Generated:** 2026-06-12T16:08:15Z
|
||||
**Commit:** 3629c33
|
||||
**Branch:** feat/bake-dag-tester
|
||||
|
||||
## OVERVIEW
|
||||
HoneyBiscuitWorkshop is a Rust-based CI/CD system ("蜜饼工坊") with LLM-powered pipeline auto-configuration. 9 independent Cargo crates, no workspace.
|
||||
HoneyBiscuitWorkshop (蜜饼工坊) — Rust-based CI/CD system inspired by Concourse CI. Pipeline: prebake.yml → bake.sh → finalize.yml. Multi-builder (Docker, Firecracker, bare metal). Plugin system.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
./
|
||||
├── workshop-baker/ # Main orchestrator (CLI + daemon)
|
||||
├── workshop-agent/ # HTTP agent server
|
||||
├── workshop-pipeline/ # Config library (prebake/finalize)
|
||||
├── workshop-llm-detector/ # LLM repo analyzer
|
||||
├── workshop-vault/ # Secret management client
|
||||
├── workshop-cert/ # TLS certificate generator
|
||||
├── workshop-deviceid/ # Device ID library
|
||||
├── workshop-builder-native/ # Stub (unused)
|
||||
├── workshop-helper-mac/ # MAC address utility
|
||||
├── docs/ # mdBook documentation (zh-CN)
|
||||
├── playground/ # Experimentation
|
||||
└── third_party/ # Empty
|
||||
├── workshop-baker/ # Main binary + lib — orchestrator, CLI, plugins
|
||||
├── workshop-engine/ # Core execution engine — process mgmt, package managers
|
||||
├── workshop-schedule/ # DAG scheduling library (petgraph-based)
|
||||
├── workshop-getterurl/ # URL fetching with hash verification
|
||||
├── workshop-baker-params/ # Config/parameter type definitions
|
||||
├── docs/ # mdBook documentation (Chinese, 3 volumes)
|
||||
├── templates/ # Pipeline config templates (prebake/bake/finalize)
|
||||
└── .github/workflows/ # CI (tests workshop-baker + workshop-engine)
|
||||
```
|
||||
**No root Cargo workspace** — crates linked by path deps. Build per-crate.
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Baker (main) | `workshop-baker/src/{bake,prebake,engine}/` | Core pipeline logic |
|
||||
| Agent server | `workshop-agent/src/` | Actix-web HTTP + TLS |
|
||||
| Pipeline config | `workshop-pipeline/src/config/` | prebake/finalize YAML |
|
||||
| LLM integration | `workshop-llm-detector/src/llm/` | Ollama/OpenAI clients |
|
||||
| Secrets | `workshop-vault/src/` | Vault client wrapper |
|
||||
| CLI definition | `workshop-baker/src/lib.rs` | Clap Cli/Commands/BareCommands |
|
||||
| Pipeline orchestration | `workshop-baker/src/main.rs` | prebake→bake→finalize→notify flow |
|
||||
| Decorator parsing | `workshop-baker/src/bake/decorator.rs` | `# @decorator(args)` syntax |
|
||||
| Build execution | `workshop-baker/src/bake/execute.rs` | Script execution with timeout/retry |
|
||||
| Docker integration | `workshop-baker/src/prebake/env/container.rs` | Bollard-based |
|
||||
| Package managers | `workshop-engine/src/pm/` | apt, pacman, dnf, apk |
|
||||
| User package managers | `workshop-engine/src/upm/` | rustup, uv, go |
|
||||
| Notification system | `workshop-baker/src/notify/` | Email, webhook, IM |
|
||||
| Plugin system | `workshop-baker/src/finalize/plugin/` | shell, dylib, rhai, internal |
|
||||
| Error types | `workshop-baker/src/error.rs` | CliError + HasExitCode |
|
||||
| Exit codes | `workshop-engine/src/error.rs` | EXITCODE_* constants |
|
||||
| DAG scheduling | `workshop-schedule/src/dag.rs` | Topological sort, batch picking |
|
||||
| Pipeline templates | `templates/` | Reference config DSL |
|
||||
|
||||
## ENTRY POINTS
|
||||
|
||||
| Entry | Type | Path | Role |
|
||||
|-------|------|------|------|
|
||||
| `workshop-baker` | Rust bin | `workshop-baker/src/main.rs` | Primary CLI — dispatch: default (full pipeline), bare (single stage), daemon (unimplemented) |
|
||||
| `workshop_baker` | Rust lib | `workshop-baker/src/lib.rs` | Baker library + CLI structs |
|
||||
| `workshop_engine` | Rust lib | `workshop-engine/src/lib.rs` | Core engine — ExecutionContext, EventSender, Executor |
|
||||
| `workshop_schedule` | Rust lib | `workshop-schedule/src/lib.rs` | Dag, DagNode, EdgeKind |
|
||||
| `workshop_baker_params` | Rust lib | `workshop-baker-params/src/lib.rs` | Layered config: defaults → base → courier |
|
||||
| `workshop_getterurl` | Rust lib | `workshop-getterurl/src/lib.rs` | GetterUrl, fetch() — http/git/file |
|
||||
| docs | mdBook | `docs/book.toml` | Project documentation site |
|
||||
|
||||
## CODE MAP
|
||||
| Symbol | Type | Location | Role |
|
||||
|--------|------|----------|------|
|
||||
| PipelineConfig | struct | workshop-pipeline/src/lib.rs:27 | Full pipeline config |
|
||||
| CICDLLMHelper | struct | workshop-llm-detector/src/lib.rs:16 | LLM analysis entry |
|
||||
| Engine | struct | workshop-baker/src/engine.rs | Build executor |
|
||||
| VaultClient | struct | workshop-vault/src/client.rs | Secret ops |
|
||||
|
||||
### Crate Dependency Graph
|
||||
```
|
||||
workshop-baker (bin)
|
||||
├── workshop-engine
|
||||
├── workshop-schedule
|
||||
├── workshop-baker-params
|
||||
└── workshop-getterurl
|
||||
```
|
||||
|
||||
### workshop-baker Module Map
|
||||
| Module | Submodules | Role |
|
||||
|--------|-----------|------|
|
||||
| `bake` | decorator, execute, parser, schedule, builder, trivial, util, constant, error | Script parsing + execution |
|
||||
| `prebake` | config, stage (bootstrap, depssystem, depsuser, environment, hook), env (container, firecracker, baremetal, custom), security, event, types, constant, error | Environment setup |
|
||||
| `finalize` | plugin (shell, dylib, rhai, internal), stage, template, types, config, constant, error, event | Post-build hooks |
|
||||
| `notify` | handler, queue, rule, template, types, util, error | Async notification dispatch |
|
||||
| `bare` | — | Standalone single-stage CLI mode |
|
||||
| `types` | resource, buildstatus | Shared type definitions |
|
||||
| `utils` | — | Cross-cutting utilities |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
### Rust
|
||||
- **Edition**: 2024 (non-standard, requires Rust 1.85+)
|
||||
- **Naming**: `snake_case` files/modules, `PascalCase` types, `SCREAMING_SNAKE_CASE` consts
|
||||
- **Error handling**: `thiserror` + `anyhow` combo
|
||||
- **Async**: `#[tokio::main]` + `tokio` with "full" features
|
||||
- **Imports**: `std` → `external_crate` → `crate::module`
|
||||
|
||||
### Testing
|
||||
- **Rust**: `#[test]` inline, `#[tokio::test]` async, `tests/*.rs` integration
|
||||
- **Python**: `pytest` with `tests/integration/test_*.py` (Docker-based)
|
||||
- **Benchmarks**: Criterion with `harness = false`
|
||||
|
||||
### Build Pipeline
|
||||
- **prebake.yml**: Environment setup (docker/firecracker/baremetal)
|
||||
- **bake.sh**: Build execution with `# @pipeline` decorators
|
||||
- **finalize.yml**: Packaging, deployment, notifications
|
||||
- **Annotations**: `@timeout()`, `@retry()`, `@parallel`, `@fallible`
|
||||
- **Edition**: Rust 2024 (requires Rust 1.85+)
|
||||
- **Formatting**: Default `rustfmt` — no `rustfmt.toml` / `.editorconfig`
|
||||
- **Lint overrides** (workshop-baker only): `dead_code = "allow"`, `unreachable_code = "allow"`, `inherent_to_string = "allow"`, `non_canonical_partial_ord_impl = "allow"`
|
||||
- **Import order**: `std` → external crates → `crate::` (blank line between groups)
|
||||
- **Error handling**: `thiserror` for libraries, `anyhow` for binaries. Implement `HasExitCode` for exit code mapping.
|
||||
- **Type system**: Newtype pattern preferred (e.g., `MemSize(u64)`). Shared types in `types/` module. `types/` must NOT depend on `bake/`, `engine/`, `prebake/`, `finalize/`.
|
||||
- **Tests**: Inline `#[cfg(test)] mod tests` at bottom of source files. Async: `#[tokio::test]`. CI runs `cargo test --all-features`.
|
||||
- **Commits**: `type(scope): description` — `feat|fix|docs|test|refactor|style`
|
||||
- **AI annotation**: `// Code in this module PARTIALLY or FULLY utilized AI Coding Agent` — never delete if present
|
||||
- **Docs language**: Chinese (zh-CN), mdBook
|
||||
- **No `no_std`**: Explicitly unsupported
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
|
||||
1. **Python in Rust project** — workshop-baker has Python tests (pytest.ini, test_prebake.py)
|
||||
2. **temp/ directory** — Non-standard in Rust projects
|
||||
3. **Certificates in source** — artifact workshop-cert/certs/ not excluded
|
||||
4. **Deprecated projects** — workshop-executor.old, workshop-monitor.old still present
|
||||
5. **Hardcoded mirrors** — Tsinghua University mirrors in configs
|
||||
6. **Rust 2024 edition** — May not work with stable toolchains
|
||||
|
||||
## UNIQUE STYLES
|
||||
|
||||
- **Pipeline DSL**: Shell scripts with `# @decorator` annotations
|
||||
- **Dual-mode baker**: CLI commands + daemon mode
|
||||
- **LLM cookbook system**: YAML cookbooks for language-specific builds
|
||||
- **Chinese docs** — Documentation primarily in zh-CN
|
||||
- **User "vulcan"** — Custom build user (not root/runner)
|
||||
- **Do NOT derive production behavior from bare mode** — bare mode is dev-only, ctx.privileged is fake
|
||||
- **Do NOT use `detect()`/`adopt()` PM methods in production** — PM selection must come from env config
|
||||
- **`drop_after < DepsUser` is FORBIDDEN** — will break system dependency installation (needs root)
|
||||
- **Do NOT add magic URL inference** — URL is URL, behavior bound explicitly via `getter::` prefix
|
||||
- **Do NOT use `@` for version locking in URLs** — it's an auth delimiter; use `?ref=`
|
||||
- **Do NOT duplicate type definitions across modules** — single source of truth in `types/`
|
||||
- **Careful with `unimplemented!()`** — Daemon mode, Rhai/Dylib plugins, and Custom builder are stubs
|
||||
- **Security**: PIPELINE_CONTAINER_PRIVILEGED, PIPELINE_BAREMETAL_ELEVATE, PIPELINE_UNCOVER_SECRET require explicit approval + audit
|
||||
|
||||
## COMMANDS
|
||||
|
||||
```bash
|
||||
# Build
|
||||
cargo build --release -p workshop-baker
|
||||
# Build (per crate, no workspace)
|
||||
cd workshop-baker && cargo build --all-features
|
||||
|
||||
# Test
|
||||
cargo test -p workshop-baker
|
||||
pytest tests/integration/test_prebake.py -v
|
||||
# Test (per crate)
|
||||
cd workshop-baker && cargo test --all-features
|
||||
cd workshop-engine && cargo test --all-features
|
||||
|
||||
# Lint
|
||||
cargo clippy -- -D warnings
|
||||
cargo fmt --all
|
||||
# Lint (CI gate)
|
||||
cargo fmt -- --check
|
||||
cargo clippy --all-features -- -D warnings
|
||||
|
||||
# Run baker
|
||||
cargo run --bin workshop-baker -- prebake config.yml
|
||||
cargo run --bin workshop-baker -- bake script.sh
|
||||
|
||||
# Run agent
|
||||
cargo run --bin workshop-agent
|
||||
# Docs
|
||||
cd docs && mdbook build # output in docs/book/
|
||||
cd docs && mdbook serve # local preview at localhost:3000
|
||||
```
|
||||
|
||||
## NOTES
|
||||
|
||||
- **Docker required** for integration tests
|
||||
- **workshop-builder-native** is a stub (3-line Hello World)
|
||||
- No root workspace Cargo.toml — each crate is independent
|
||||
- `AGENTS.md.old` at root — legacy documentation
|
||||
- **No workspace Cargo.toml** at root — each crate has its own `Cargo.lock`. Build individually.
|
||||
- **CI only tests** `workshop-baker` and `workshop-engine`; smaller crates tested transitively.
|
||||
- **`quicktest.sh`** in workshop-baker/ is a dev smoke-test script (Docker-based, not CI).
|
||||
- **Git submodules**: `rust-tongsuo`, `RustyVault` (crypto — not yet integrated into build).
|
||||
- **`.secret/`** contains real credentials — never commit, never read into context.
|
||||
- See `docs/src/zh-CN/vol3_dev/` for full developer documentation (architecture, design decisions, code style).
|
||||
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
diff --git a/workshop-baker/src/notify.rs b/workshop-baker/src/notify.rs
|
||||
index 75bce82..ad56c66 100644
|
||||
--- a/workshop-baker/src/notify.rs
|
||||
+++ b/workshop-baker/src/notify.rs
|
||||
@@ -3,15 +3,15 @@ use crate::finalize::FinalizeConfig;
|
||||
use crate::finalize::parse;
|
||||
use crate::finalize::plugin;
|
||||
use crate::finalize::plugin::fetch;
|
||||
-use crate::notify::types::BundledNotifyEvent;
|
||||
+use crate::notify::types::BundledNotificationEvent;
|
||||
use crate::notify::types::NOTIFY_TEMPORARY_DELAY_FACTOR;
|
||||
use crate::notify::types::NOTIFY_TEMPORARY_DELAY_TIME;
|
||||
use crate::notify::types::NOTIFY_TEMPORARY_MAX_TIME;
|
||||
use crate::notify::types::NotificationRulesMap;
|
||||
-use crate::notify::types::NotifyEvent;
|
||||
-use crate::notify::types::NotifyEventReceiver;
|
||||
-use crate::notify::types::NotifyEventSender;
|
||||
-use crate::notify::types::NotifyPluginMap;
|
||||
+use crate::notify::types::NotificationEvent;
|
||||
+use crate::notify::types::NotificationEventReceiver;
|
||||
+use crate::notify::types::NotificationEventSender;
|
||||
+use crate::notify::types::NotificationPluginMap;
|
||||
use crate::{EventSender, ExecutionContext};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
@@ -46,8 +46,8 @@ pub async fn notify(
|
||||
finalize_path: &Path,
|
||||
ctx: &mut ExecutionContext,
|
||||
event_tx: EventSender,
|
||||
- notifyevent_rx: &mut NotifyEventReceiver,
|
||||
- notifyevent_tx: NotifyEventSender,
|
||||
+ notifyevent_rx: &mut NotificationEventReceiver,
|
||||
+ notifyevent_tx: NotificationEventSender,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut finalize = parse(finalize_path)?;
|
||||
validate(&finalize)?;
|
||||
@@ -114,10 +114,11 @@ pub async fn notify(
|
||||
}
|
||||
|
||||
fn notification_handler(
|
||||
- plugins: &NotifyPluginMap,
|
||||
+ plugins: &NotificationPluginMap,
|
||||
config: &NotificationRulesMap,
|
||||
- event: &BundledNotifyEvent,
|
||||
+ event: &BundledNotificationEvent,
|
||||
) -> Result<(), NotifyError> {
|
||||
+
|
||||
todo!();
|
||||
}
|
||||
|
||||
@@ -132,7 +133,7 @@ fn wait_time(group_period: u32, group_watermark: u32, count: u32) -> f32 {
|
||||
}
|
||||
|
||||
// calculate p^(m-l)*t as notification delay
|
||||
-fn notify_delay(event: &NotifyEvent) -> std::time::Duration {
|
||||
+fn notify_delay(event: &NotificationEvent) -> std::time::Duration {
|
||||
let exponent = (event.max_lives - event.lives) as i32;
|
||||
if exponent < 0 {
|
||||
log::warn!(
|
||||
diff --git a/workshop-baker/src/notify/types.rs b/workshop-baker/src/notify/types.rs
|
||||
index b852dfb..d6ccfb7 100644
|
||||
--- a/workshop-baker/src/notify/types.rs
|
||||
+++ b/workshop-baker/src/notify/types.rs
|
||||
@@ -39,13 +39,13 @@ pub struct NotificationRule{
|
||||
pub type Priority = u32;
|
||||
pub type Method = String;
|
||||
pub type NotificationRulesMap = HashMap<Priority, HashMap<Method, Vec<NotificationRule>>>;
|
||||
-pub type BundledNotifyEvent = Vec<NotifyEvent>;
|
||||
-pub type NotifyEventReceiver = tokio::sync::mpsc::Receiver<BundledNotifyEvent>;
|
||||
-pub type NotifyEventSender = tokio::sync::mpsc::Sender<NotifyEvent>;
|
||||
-pub type NotifyPluginMap = HashMap<String, FinalizePlugin>;
|
||||
+pub type BundledNotificationEvent = Vec<NotificationEvent>;
|
||||
+pub type NotificationEventReceiver = tokio::sync::mpsc::Receiver<BundledNotificationEvent>;
|
||||
+pub type NotificationEventSender = tokio::sync::mpsc::Sender<NotificationEvent>;
|
||||
+pub type NotificationPluginMap = HashMap<String, FinalizePlugin>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
-pub struct NotifyEvent {
|
||||
+pub struct NotificationEvent {
|
||||
pub id: Uuid,
|
||||
pub stage: StagePhase,
|
||||
pub substage: String,
|
||||
@@ -58,21 +58,21 @@ pub struct NotifyEvent {
|
||||
pub target: HashSet<String>, // Target audience
|
||||
}
|
||||
|
||||
-impl Hash for NotifyEvent {
|
||||
+impl Hash for NotificationEvent {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.id.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
-impl PartialEq for NotifyEvent {
|
||||
+impl PartialEq for NotificationEvent {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
-impl Eq for NotifyEvent {}
|
||||
+impl Eq for NotificationEvent {}
|
||||
|
||||
-impl PartialOrd for NotifyEvent {
|
||||
+impl PartialOrd for NotificationEvent {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(
|
||||
self.effective_priority
|
||||
@@ -83,7 +83,7 @@ impl PartialOrd for NotifyEvent {
|
||||
}
|
||||
}
|
||||
|
||||
-impl Ord for NotifyEvent {
|
||||
+impl Ord for NotificationEvent {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.partial_cmp(other).unwrap()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# DOCS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
mdBook project in zh-CN. Authoritative source for project conventions, coding style, and architecture docs. Build output at `docs/book/` (gitignored).
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
docs/
|
||||
├── book.toml # mdBook config (title, language=zh-CN)
|
||||
├── src/SUMMARY.md # Sidebar nav — source of truth for page listing
|
||||
└── src/zh-CN/
|
||||
├── index.md # Landing
|
||||
├── vol1_user/ # End-user documentation
|
||||
├── vol2_admin/ # Admin reference: decorator cheatsheet, deployment,
|
||||
│ # env vars, exit codes, plugin ref, security, troubleshooting
|
||||
└── vol3_dev/ # Developer docs: architecture, code style, commit
|
||||
# convention, testing, error handling, type system,
|
||||
# design decisions, PR process
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Sidebar/nav editing | `src/SUMMARY.md` | Add entry here to surface a new page |
|
||||
| Coding conventions | `vol3_dev/code_style.md` | Naming, imports, formatting |
|
||||
| Commit rules | `vol3_dev/commit_convention.md` | feat/fix/docs/test/refactor/style |
|
||||
| Error handling | `vol3_dev/error_handling_patterns.md` | thiserror+anyhow+HasExitCode |
|
||||
| Type system | `vol3_dev/type_system_guide.md` | Newtypes, types/ module isolation |
|
||||
| Testing strategy | `vol3_dev/testing_strategy.md` | 3 tiers: unit, integration, system |
|
||||
| Anti-patterns | `vol3_dev/design_decisions.md` | Documented design rejections |
|
||||
| Architecture | `vol3_dev/architecture.md` | Crate dependency graph, module map |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **Language**: All prose in zh-CN. Code blocks in Rust (default) or bash.
|
||||
- **Adding a page**: Create `.md` under `volN_*/`, then add to `SUMMARY.md`.
|
||||
- **Formatting**: Standard Markdown. mdBook preprocessors handle TOC and links.
|
||||
- **Cross-references**: Relative paths from source root (e.g., `../vol3_dev/code_style.md`).
|
||||
- **Code blocks**: Fenced with language tag. No line numbers in docs.
|
||||
- **File naming**: `snake_case.md`.
|
||||
|
||||
## COMMANDS
|
||||
|
||||
```bash
|
||||
mdbook build # static site → docs/book/
|
||||
mdbook serve # live preview at localhost:3000
|
||||
```
|
||||
@@ -8,4 +8,31 @@
|
||||
- [PIPELINE_BAREMETAL_PKGMAN](zh-CN/vol2_admin/pipeline_baremetal_pkgman.md)
|
||||
- [PIPELINE_BAREMETAL_ELEVATE](zh-CN/vol2_admin/pipeline_baremetal_elevate.md)
|
||||
- [PIPELINE_CONTAINER_PRIVILEGED](zh-CN/vol2_admin/pipeline_container_privileged.md)
|
||||
- [环境变量](zh-CN/vol2_admin/environment_variables.md)
|
||||
- [Socket 配置](zh-CN/vol2_admin/socket_config.md)
|
||||
- [工作空间](zh-CN/vol2_admin/workspace.md)
|
||||
- [部署](zh-CN/vol2_admin/deployment.md)
|
||||
- [故障排除](zh-CN/vol2_admin/troubleshooting.md)
|
||||
- [Decorator 速查表](zh-CN/vol2_admin/decorator_cheatsheet.md)
|
||||
- [Prebake 配置参考](zh-CN/vol2_admin/prebake_config_reference.md)
|
||||
- [Finalize 插件参考](zh-CN/vol2_admin/finalize_plugin_reference.md)
|
||||
- [退出码速查](zh-CN/vol2_admin/exit_code_guide.md)
|
||||
- [构建状态文件格式](zh-CN/vol2_admin/status_file_format.md)
|
||||
- [卷3 开发者手册](zh-CN/vol3_dev/index.md)
|
||||
- [架构](zh-CN/vol3_dev/architecture.md)
|
||||
- [开发环境](zh-CN/vol3_dev/development_environment.md)
|
||||
- [代码规范](zh-CN/vol3_dev/code_style.md)
|
||||
- [提交规范](zh-CN/vol3_dev/commit_convention.md)
|
||||
- [PR 流程](zh-CN/vol3_dev/pr_process.md)
|
||||
- [错误处理模式](zh-CN/vol3_dev/error_handling_patterns.md)
|
||||
- [类型系统指南](zh-CN/vol3_dev/type_system_guide.md)
|
||||
- [测试策略](zh-CN/vol3_dev/testing_strategy.md)
|
||||
- [插件系统指南](zh-CN/vol3_dev/plugin_system_guide.md)
|
||||
- [Engine 模块指南](zh-CN/vol3_dev/engine_module_guide.md)
|
||||
- [设计决策](zh-CN/vol3_dev/design_decisions.md)
|
||||
- [添加包管理器](zh-CN/vol3_dev/add_package_manager.md)
|
||||
- [自定义插件](zh-CN/vol3_dev/custom_plugin.md)
|
||||
- [自定义 Decorator](zh-CN/vol3_dev/custom_decorator.md)
|
||||
- [模板变量](zh-CN/vol3_dev/template_variables.md)
|
||||
- [邮件模板](zh-CN/vol3_dev/mail_template.md)
|
||||
- [Resource 模型](zh-CN/vol3_dev/resource_model.md)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Decorator 速查表
|
||||
|
||||
bake.sh 中所有可用的 `# @decorator` 注解:
|
||||
|
||||
| Decorator | 语法 | 作用 | 示例 |
|
||||
|-----------|------|------|------|
|
||||
| `@pipeline` | `# @pipeline` | 标记为主构建步骤 | 无参数 |
|
||||
| `@after` | `# @after(func_name)` | 指定依赖的前置步骤 | `# @after(build)` |
|
||||
| `@timeout` | `# @timeout(N)` | 超时时间(秒) | `# @timeout(300)` |
|
||||
| `@retry` | `# @retry(count, delay)` | 失败重试次数与间隔(秒) | `# @retry(3, 10)` |
|
||||
| `@if` | `# @if(condition)` | 条件执行(shell 条件表达式) | `# @if([ -f Makefile ])` |
|
||||
| `@fallible` | `# @fallible` | 允许失败,不中断流水线 | 无参数 |
|
||||
| `@parallel` | `# @parallel` | 与其他 `@parallel` 步骤并发执行 | 无参数 |
|
||||
| `@loop` | `# @loop(N)` | 重复执行 N 次 | `# @loop(5)` |
|
||||
| `@export` | `# @export` | 导出环境变量供后续步骤使用 | 无参数 |
|
||||
| `@pipe` | `# @pipe(f1, f2, ...)` | 管道串联多个函数 | `# @pipe(lint, build)` |
|
||||
| `@daemon` | `# @daemon` | 作为后台守护进程运行 | 无参数 |
|
||||
| `@health` | `# @health(endpoint)` | 指定健康检查端点 | `# @health(/health)` |
|
||||
|
||||
## 语法规则
|
||||
|
||||
- `#` 和 `@` 之间必须有空格:`# @pipeline` 合法,`#@pipeline` 不合法
|
||||
- `@name` 和 `(args)` 之间不能有空格:`@timeout(60)` 合法,`@timeout (60)` 不合法
|
||||
- 每个 decorator 独占一行,位于目标函数定义的上方
|
||||
|
||||
## 常见组合
|
||||
|
||||
```bash
|
||||
# @pipeline
|
||||
# @after(setup)
|
||||
# @timeout(600)
|
||||
# @retry(2, 30)
|
||||
build() {
|
||||
cargo build --release
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,129 @@
|
||||
# 部署
|
||||
|
||||
## 安装
|
||||
|
||||
### 从源码构建
|
||||
|
||||
```bash
|
||||
cargo build --release -p workshop-baker
|
||||
```
|
||||
|
||||
编译输出位于 `target/release/workshop-baker`。
|
||||
|
||||
### 下载预编译二进制
|
||||
|
||||
从项目 Release 页面下载对应平台的预编译二进制文件,放置到 `$PATH` 可达路径即可。
|
||||
|
||||
## 运行模式
|
||||
|
||||
Baker 支持两种运行模式:
|
||||
|
||||
### CLI 模式
|
||||
|
||||
单次执行模式,每次运行完成一个构建阶段后退出:
|
||||
|
||||
```bash
|
||||
# 预烘焙阶段
|
||||
workshop-baker -u <username> -p <pipeline> -b <build-id> bare prebake config.yml
|
||||
|
||||
# 烘焙阶段
|
||||
workshop-baker -u <username> -p <pipeline> -b <build-id> bare bake script.sh \
|
||||
--bake-base ./bake_base.sh --prebake ./prebake.yml
|
||||
|
||||
# 终结阶段
|
||||
workshop-baker -u <username> -p <pipeline> -b <build-id> bare finalize config.yml
|
||||
```
|
||||
|
||||
### Daemon 模式
|
||||
|
||||
常驻后台模式通过 Socket 监听请求,支持多构建任务管理。当前处于重构中,暂未实现。Daemon 模式下不指定 `bare` 子命令即可进入:
|
||||
|
||||
```bash
|
||||
workshop-baker -u <username> -p <pipeline> -b <build-id>
|
||||
```
|
||||
|
||||
## 预烘焙环境
|
||||
|
||||
prebake 阶段负责准备构建运行环境,支持三种方式:
|
||||
|
||||
### Docker
|
||||
|
||||
在容器内执行构建,通过配置文件指定容器参数:
|
||||
|
||||
```yaml
|
||||
prebake:
|
||||
type: docker
|
||||
image: "rust:1.85"
|
||||
dockerfile: "Dockerfile.build"
|
||||
build_args:
|
||||
- "CARGO_PROFILE=release"
|
||||
```
|
||||
|
||||
- `image` — 指定预构建的 Docker 镜像
|
||||
- `dockerfile` — 指定自定义 Dockerfile 路径(与 `image` 二选一)
|
||||
- `build_args` — Docker 构建参数列表
|
||||
|
||||
### Firecracker
|
||||
|
||||
在 microVM 内执行构建,提供更强的隔离性:
|
||||
|
||||
```yaml
|
||||
prebake:
|
||||
type: firecracker
|
||||
kernel: "/path/to/vmlinux"
|
||||
rootfs: "/path/to/rootfs.ext4"
|
||||
vcpu_count: 2
|
||||
mem_size_mb: 512
|
||||
```
|
||||
|
||||
### Baremetal
|
||||
|
||||
直接在宿主机运行构建,不使用容器或虚拟机隔离:
|
||||
|
||||
```yaml
|
||||
prebake:
|
||||
type: baremetal
|
||||
pkgman: "apt"
|
||||
elevate: true
|
||||
```
|
||||
|
||||
## Systemd 服务
|
||||
|
||||
以下为 Baker Daemon 的 systemd unit 文件示例:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=HoneyBiscuitWorkshop Baker Daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/workshop-baker -u vulcan -p default -b 0 --socket unix:///run/hbw.sock
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=RUST_LOG=info
|
||||
Environment=HBW_WORKSPACE=/workspace
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
部署步骤:
|
||||
|
||||
```bash
|
||||
# 安装 unit 文件
|
||||
sudo cp workshop-baker.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now workshop-baker
|
||||
```
|
||||
|
||||
## 权限模型
|
||||
|
||||
Baker 采用"先提权后降权"的权限模型:
|
||||
|
||||
| 阶段 | 执行用户 | 原因 |
|
||||
|------|----------|------|
|
||||
| prebake | root | 需要创建容器/虚拟机、挂载文件系统等特权操作 |
|
||||
| bake | vulcan | 降权执行,限制构建过程权限 |
|
||||
|
||||
prebake 阶段以 root 身份运行,完成环境准备工作后,Baker 会将执行权限降级为 `vulcan` 用户再进入 bake 阶段,确保构建过程不会以 root 权限运行。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 环境变量
|
||||
|
||||
HoneyBiscuitWorkshop 通过以下环境变量控制构建行为:
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `HBW_USERNAME` | 提交者用户名(来自 base / server) | — |
|
||||
| `HBW_PIPELINE` | 流水线标识符 | — |
|
||||
| `HBW_BUILD_ID` | 构建唯一 ID | — |
|
||||
| `HBW_DRY_RUN` | 干运行模式,不执行实际构建 | — |
|
||||
| `HBW_WORKSPACE` | 工作空间路径 | `/workspace` |
|
||||
| `RUST_LOG` | 日志级别 (error/warn/info/debug/trace) | `warn` |
|
||||
|
||||
### HBW_USERNAME
|
||||
|
||||
提交者用户名,由上游 server / base 系统传入,标识触发本次构建的用户。与构建系统内的 Unix/Windows 用户名(如 `vulcan`)无关,仅用于日志、通知与审计追踪。
|
||||
|
||||
### HBW_PIPELINE
|
||||
|
||||
标识当前运行的流水线。在 prebake 阶段自动从配置文件获取,贯穿整个构建生命周期。
|
||||
|
||||
### HBW_BUILD_ID
|
||||
|
||||
每次构建的唯一标识符,用于日志追踪和状态标识。
|
||||
|
||||
### HBW_DRY_RUN
|
||||
|
||||
设置为任意非空值时启用干运行模式。该模式下 Baker 会解析流水线配置并生成执行计划,但不会执行实际构建操作。适用于配置验证和调试。
|
||||
|
||||
### HBW_WORKSPACE
|
||||
|
||||
构建工作空间的绝对路径。所有构建输出和中间文件都将输出到此目录下。
|
||||
|
||||
### RUST_LOG
|
||||
|
||||
控制日志输出级别,遵循 `tracing` crate 的规范:
|
||||
|
||||
- `error` — 仅输出错误
|
||||
- `warn` — 输出警告和错误
|
||||
- `info` — 输出一般信息
|
||||
- `debug` — 输出调试信息
|
||||
- `trace` — 输出全部跟踪信息
|
||||
|
||||
支持模块级别过滤,例如 `RUST_LOG=workshop_baker=trace` 仅跟踪 Baker 模块。
|
||||
@@ -0,0 +1,20 @@
|
||||
# 退出码速查
|
||||
|
||||
| 退出码 | 常量名 | 含义 | 典型触发场景 |
|
||||
|--------|--------|------|-------------|
|
||||
| 0 | `EXITCODE_OK` | 成功 | 流水线正常完成 |
|
||||
| 1 | `EXITCODE_GENERAL_ERROR` | 通用错误 | 未分类的运行时错误 |
|
||||
| 2 | `EXITCODE_INVALID_ARGS` | 参数错误 | 命令行参数或配置无效 |
|
||||
| 3 | `EXITCODE_IO_ERROR` | IO 错误 | 文件读写、目录创建失败 |
|
||||
| 4 | `EXITCODE_PARSE_ERROR` | 解析错误 | YAML 配置或 Decorator 语法错误 |
|
||||
| 5 | `EXITCODE_EXECUTION_ERROR` | 执行错误 | 构建脚本执行失败 |
|
||||
| 124 | `EXITCODE_TIMEOUT` | 超时 | 构建超过 `@timeout()` 限制 |
|
||||
| 201 | `EXITCODE_PRIV_DROP_FAILED` | 权限降级失败 | 无法从 root 切换到 vulcan 用户 |
|
||||
| 233 | `PIPELINE_SKIP_ERRORCODE` | 流水线跳过 | `@if` 条件不满足,步骤跳过 |
|
||||
|
||||
## 排查命令
|
||||
|
||||
```bash
|
||||
# 结合日志定位具体错误
|
||||
RUST_LOG=debug workshop-baker -u testuser -p my-pipeline -b 1 bare bake script.sh 2>&1 | grep -i "error\|fail"
|
||||
```
|
||||
@@ -0,0 +1,91 @@
|
||||
# Finalize 插件参考
|
||||
|
||||
Finalize 阶段通过插件系统完成打包、部署与通知。
|
||||
|
||||
## 插件类型
|
||||
|
||||
| 类型 | 说明 | 配置位置 |
|
||||
|------|------|---------|
|
||||
| `internal` | 内置插件(随 baker 编译) | `finalize.yml` 的 `notification` 下直接配置 |
|
||||
| `shell` | 可执行脚本插件 | 提供 `manifest.yml` 描述文件 |
|
||||
| `dylib` | 动态链接库插件 | 通过 `dlopen` 加载 |
|
||||
| `rhai` | Rhai 脚本引擎插件 | `.rhai` 脚本文件(暂未实现) |
|
||||
|
||||
## 内置插件清单
|
||||
|
||||
### mail — SMTP 邮件通知
|
||||
|
||||
发送构建结果邮件,支持 HTML 模板与多收件人。
|
||||
|
||||
| 字段 | 必需 | 类型 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `to` | 是 | String / List | 收件人地址,支持模板变量 |
|
||||
| `from` | 是 | String / Object | 发件人,单字符串为 email,Object 可含 `name` 与 `email` |
|
||||
| `cc` | 否 | List | 抄送 |
|
||||
| `bcc` | 否 | List | 密送 |
|
||||
| `reply_to` | 否 | String | 回复地址 |
|
||||
| `subject` | 否 | String | 邮件主题,支持模板语法 |
|
||||
| `body` | 否 | String | 邮件正文,支持模板语法与 HTML |
|
||||
| `schema` | 否 | String | `"default"` 或 `"custom"`,控制是否使用内置模板 |
|
||||
| `smtp` | 是 | Object | SMTP 服务器配置 |
|
||||
| `timeout` | 否 | String | 发送超时,如 `"90s"` |
|
||||
| `retry` | 否 | Integer | 失败重试次数 |
|
||||
| `fallible` | 否 | Boolean | 允许失败不中断流水线 |
|
||||
|
||||
#### smtp 子字段
|
||||
|
||||
| 字段 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `host` | 是 | SMTP 服务器地址 |
|
||||
| `port` | 是 | 端口号 |
|
||||
| `auth.type` | 否 | `"password"` / `"oauth2"` / `"none"` |
|
||||
| `auth.username` | 条件 | `type=password` 时必需 |
|
||||
| `auth.password` | 条件 | `type=password` 时必需 |
|
||||
| `encryption` | 否 | `"tls"` / `"starttls"` / `"none"` |
|
||||
| `connect_timeout` | 否 | 连接超时 |
|
||||
|
||||
#### 模板变量
|
||||
|
||||
| 变量 | 示例值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `{{ build.status }}` | `success` | 构建状态 |
|
||||
| `{{ build.id }}` | `42` | 构建编号 |
|
||||
| `{{ build.duration }}` | `125s` | 构建耗时 |
|
||||
| `{{ build.url }}` | `https://ci.example.com/42` | 构建详情链接 |
|
||||
| `{{ pipeline.name }}` | `my-project` | 流水线名称 |
|
||||
| `{{ commit.hash }}` | `abc1234` | 提交哈希 |
|
||||
| `{{ commit.author }}` | `Alice` | 提交者 |
|
||||
| `{{ commit.message }}` | `fix: typo` | 提交信息 |
|
||||
| `{{ secret.xxx }}` | — | Vault 密钥引用 |
|
||||
|
||||
### webhook — HTTP 回调
|
||||
|
||||
向外部服务发送 HTTP POST 请求。
|
||||
|
||||
| 字段 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `url` | 是 | 回调地址 |
|
||||
| `method` | 否 | HTTP 方法,默认 `POST` |
|
||||
| `headers` | 否 | 请求头键值对 |
|
||||
| `body` | 否 | 请求体,支持模板变量 |
|
||||
| `timeout` | 否 | 请求超时 |
|
||||
| `retry` | 否 | 失败重试次数 |
|
||||
| `fallible` | 否 | 允许失败 |
|
||||
|
||||
## 最小配置示例
|
||||
|
||||
```yaml
|
||||
notification:
|
||||
1:
|
||||
mail:
|
||||
to: "admin@example.com"
|
||||
from: "ci@example.com"
|
||||
smtp:
|
||||
host: "smtp.gmail.com"
|
||||
port: 587
|
||||
auth:
|
||||
type: "password"
|
||||
username: "{{ secret.smtp_user }}"
|
||||
password: "{{ secret.smtp_pass }}"
|
||||
encryption: "starttls"
|
||||
```
|
||||
@@ -0,0 +1,119 @@
|
||||
# Prebake 配置参考
|
||||
|
||||
`prebake.yml` 定义构建环境的准备阶段。
|
||||
|
||||
## 顶层字段
|
||||
|
||||
| 字段 | 必需 | 类型 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `version` | 是 | String | — | 语义化版本,须满足 `^1.0` |
|
||||
| `environment` | 是 | Object | — | 构建环境类型与资源配置 |
|
||||
| `bootstrap` | 否 | Object | 见下文 | 用户、工作目录初始化 |
|
||||
| `dependencies` | 否 | Object | — | 系统包与用户包依赖 |
|
||||
| `envvars` | 否 | Object | — | 环境变量注入 |
|
||||
| `cache` | 否 | Object | — | 缓存目录与策略 |
|
||||
| `security` | 否 | Object | — | 权限降级配置 |
|
||||
| `hooks` | 否 | Object | — | 前置/后置钩子 |
|
||||
| `metadata` | 否 | Object | — | 流水线元信息 |
|
||||
|
||||
## environment
|
||||
|
||||
| 字段 | 必需 | 类型 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `builder` | 是 | String | — | `baremetal` / `docker` / `firecracker` / `custom` |
|
||||
| `resources` | 否 | Object | 见下文 | CPU、内存、磁盘、架构约束 |
|
||||
| `network` | 否 | Object | 见下文 | 网络开关与代理 |
|
||||
|
||||
### resources 默认值
|
||||
|
||||
| 字段 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `cpu` | `1` | vCPU 数量 |
|
||||
| `memory` | `1 GiB` | 内存限制 |
|
||||
| `disk` | `1 GiB` | 磁盘限制 |
|
||||
| `architecture` | `[]` | 允许的目标架构,支持字符串或数组 |
|
||||
| `tags` | `[]` | 节点标签筛选 |
|
||||
|
||||
### network
|
||||
|
||||
| 字段 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `enabled` | `true` | 是否启用网络 |
|
||||
| `outbound` | `true` | 是否允许出站连接 |
|
||||
| `dns_servers` | `[]` | 自定义 DNS 服务器列表 |
|
||||
| `proxies` | `[]` | HTTP/HTTPS 代理配置 |
|
||||
|
||||
## bootstrap
|
||||
|
||||
| 字段 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `user` | `"vulcan"` | 构建用户 |
|
||||
| `workspace.path` | `"/home/vulcan/workspace"` | 工作目录 |
|
||||
| `workspace.fallback` | `true` | 目录不存在时自动创建 |
|
||||
| `sudoers` | `null` | sudoers 文件内容 |
|
||||
| `doas` | `null` | doas.conf 文件内容 |
|
||||
| `custom` | `null` | 自定义初始化脚本 |
|
||||
|
||||
## dependencies
|
||||
|
||||
### system
|
||||
|
||||
以包管理器名为键,每个值包含:
|
||||
|
||||
| 字段 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `packages` | 是 | 包名列表 |
|
||||
| `repositories` | 否 | 额外软件源配置 |
|
||||
| `mirror` | 否 | 镜像地址 |
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
system:
|
||||
pacman:
|
||||
packages: ["curl", "wget"]
|
||||
```
|
||||
|
||||
### user
|
||||
|
||||
以包管理器名为键,值为自由格式(由对应 UPM 解析)。
|
||||
|
||||
## envvars
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `prebake` | 在 prebake 阶段注入的环境变量 |
|
||||
| `bake` | 在 bake 阶段注入的环境变量 |
|
||||
|
||||
## cache
|
||||
|
||||
| 字段 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `directory` | 是 | 缓存目录列表,每项含 `path` 与可选 `strategy` |
|
||||
| `strategy` | 否 | 缓存策略配置 |
|
||||
|
||||
## 完整示例
|
||||
|
||||
```yaml
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
resources:
|
||||
cpu: 2
|
||||
memory: "4 GiB"
|
||||
architecture: ["x86_64", "aarch64"]
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
workspace:
|
||||
path: "/home/vulcan/workspace"
|
||||
fallback: true
|
||||
dependencies:
|
||||
system:
|
||||
pacman:
|
||||
packages: ["rust", "git"]
|
||||
envvars:
|
||||
prebake:
|
||||
RUST_BACKTRACE: "1"
|
||||
cache:
|
||||
directory:
|
||||
- path: "/var/cache/pacman"
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
# Socket 配置
|
||||
|
||||
Baker Daemon 通过 Socket 与 CLI 通信,支持以下连接方式:
|
||||
|
||||
- **Unix Domain Socket**: `unix://path` — 使用本地 Unix 域套接字,适用于同机通信
|
||||
```bash
|
||||
workshop-baker -u vulcan -p default -b 0 --socket unix:///run/hbw.sock
|
||||
```
|
||||
- **TCP Socket**: `tcp://host:port` — 使用 TCP 连接,适用于远程管理
|
||||
```bash
|
||||
workshop-baker -u vulcan -p default -b 0 --socket tcp://127.0.0.1:9090
|
||||
```
|
||||
- **Dryrun 模式**: `dryrun` — 测试模式,不创建实际连接
|
||||
```bash
|
||||
workshop-baker -u vulcan -p default -b 0 --socket dryrun
|
||||
```
|
||||
|
||||
默认使用 `unix:///run/hbw.sock`。
|
||||
@@ -0,0 +1,33 @@
|
||||
# 构建状态文件格式
|
||||
|
||||
Baker 将构建状态写入 `/tmp/.hbwstatus`,格式为 **JSON Lines**(每行一条独立 JSON)。
|
||||
|
||||
## 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `name` | String | 阶段名称 |
|
||||
| `phase` | String | 执行阶段:`prebake` / `bake` / `finalize` |
|
||||
| `substage` | String | 子阶段标识 |
|
||||
| `result` | String | 结果:`success` / `failure` / `canceled` / `skipped` |
|
||||
| `duration_ms` | u64 | 执行耗时(毫秒) |
|
||||
| `started_at` | ISO 8601 | 开始时间 |
|
||||
| `finished_at` | ISO 8601 | 结束时间 |
|
||||
| `error_message` | String/Null | 失败时的错误信息 |
|
||||
|
||||
## 示例
|
||||
|
||||
```json
|
||||
{"name":"bootstrap","phase":"Prebake","substage":"bootstrap","result":"success","duration_ms":1500,"started_at":"2026-04-25T10:00:00Z","finished_at":"2026-04-25T10:00:01.5Z","error_message":null}
|
||||
{"name":"build","phase":"Bake","substage":"build","result":"failure","duration_ms":30000,"started_at":"2026-04-25T10:01:00Z","finished_at":"2026-04-25T10:01:30Z","error_message":"cargo build failed with exit code 101"}
|
||||
```
|
||||
|
||||
## 读取状态
|
||||
|
||||
```bash
|
||||
# 查看最新阶段
|
||||
tail -1 /tmp/.hbwstatus | jq .
|
||||
|
||||
# 统计失败阶段数
|
||||
cat /tmp/.hbwstatus | jq -s 'map(select(.result == "failure")) | length'
|
||||
```
|
||||
@@ -0,0 +1,102 @@
|
||||
# 故障排除
|
||||
|
||||
## 日志调试
|
||||
|
||||
启用详细日志输出以定位问题:
|
||||
|
||||
```bash
|
||||
# 查看 Baker 详细日志
|
||||
RUST_LOG=debug workshop-baker -u testuser -p my-pipeline -b 1 bare bake script.sh
|
||||
|
||||
# 查看特定模块日志
|
||||
RUST_LOG=workshop_baker=trace workshop-baker -u testuser -p my-pipeline -b 1 bare bake script.sh
|
||||
|
||||
# 组合过滤
|
||||
RUST_LOG=workshop_baker=debug,workshop_pipeline=trace workshop-baker -u testuser -p my-pipeline -b 1 bare bake script.sh
|
||||
```
|
||||
|
||||
常用日志级别组合:
|
||||
|
||||
- `RUST_LOG=info` — 生产环境推荐
|
||||
- `RUST_LOG=debug` — 一般调试
|
||||
- `RUST_LOG=workshop_baker=trace` — 深度调试 Baker 内部逻辑
|
||||
|
||||
## 退出码对照表
|
||||
|
||||
| 退出码 | 含义 | 说明 |
|
||||
|--------|------|------|
|
||||
| 0 | OK | 构建成功完成 |
|
||||
| 1 | 通用错误 | 未分类的运行时错误 |
|
||||
| 2 | 参数错误 | 命令行参数或配置无效 |
|
||||
| 3 | IO 错误 | 文件读写失败 |
|
||||
| 4 | 解析错误 | YAML 配置或 Decorator 语法解析失败 |
|
||||
| 5 | 执行错误 | 构建脚本执行失败 |
|
||||
| 124 | 超时 | 构建超过 `@timeout()` 指定的时间限制 |
|
||||
| 201 | 权限降级失败 | 无法从 root 切换到 vulcan 用户 |
|
||||
| 233 | 流水线跳过 | 流水线条件不满足,跳过执行 |
|
||||
|
||||
## 常见问题与解决
|
||||
|
||||
### Socket 连接失败
|
||||
|
||||
**现象**: CLI 无法连接 Daemon,报 Connection refused 或 Permission denied。
|
||||
|
||||
**解决**: 检查 Unix socket 路径是否存在以及文件权限:
|
||||
|
||||
```bash
|
||||
# 检查 socket 文件
|
||||
ls -la /run/hbw.sock
|
||||
|
||||
# 确认 Daemon 正在运行
|
||||
pgrep -a workshop-baker
|
||||
|
||||
# 检查 socket 权限
|
||||
# 确保当前用户有读写权限,或使用 sudo
|
||||
```
|
||||
|
||||
### Decorator 解析错误
|
||||
|
||||
**现象**: 构建脚本的 Decorator 注解无法识别。
|
||||
|
||||
**解决**: Decorator 语法必须严格遵循 `# @name(args)` 格式。常见错误:
|
||||
|
||||
```bash
|
||||
# 错误 — @ 符号前缺少空格
|
||||
#@timeout(60)
|
||||
|
||||
# 错误 — 名称后有空格
|
||||
# @ timeout(60)
|
||||
|
||||
# 正确
|
||||
# @timeout(60)
|
||||
```
|
||||
|
||||
注意:`#` 和 `@` 之间必须有空格,`@name` 和 `(args)` 之间不能有空格。
|
||||
|
||||
### Hook 执行权限拒绝
|
||||
|
||||
**现象**: prebake 或 finalize 阶段的 Hook 脚本报 Permission denied。
|
||||
|
||||
**解决**: 检查 `vulcan` 用户的 sudoers 配置,确保其有执行必要操作的权限:
|
||||
|
||||
```bash
|
||||
# 检查 sudoers 配置
|
||||
sudo visudo -c
|
||||
|
||||
# 确认 vulcan 用户权限
|
||||
sudo -lU vulcan
|
||||
```
|
||||
|
||||
### 包管理器检测失败
|
||||
|
||||
**现象**: baremetal 模式下 Baker 无法检测系统包管理器。
|
||||
|
||||
**解决**: 检查 `/etc/os-release` 文件是否存在且内容完整:
|
||||
|
||||
```bash
|
||||
# 检查文件
|
||||
cat /etc/os-release
|
||||
|
||||
# 常见缺失情况:Docker 最小镜像或 musl 环境
|
||||
# 需手动安装 base-files 或创建该文件
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
# 工作空间
|
||||
|
||||
工作空间是 Baker 执行构建的根目录,默认路径为 `/workspace`。目录结构如下:
|
||||
|
||||
```
|
||||
/workspace/
|
||||
├── src/ # 源代码检出目录
|
||||
├── build/ # 编译中间输出
|
||||
├── output/ # 构建最终输出
|
||||
└── cache/ # 构建缓存
|
||||
```
|
||||
|
||||
可通过 `HBW_WORKSPACE` 环境变量或配置文件覆盖默认路径。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 添加包管理器
|
||||
|
||||
要支持新的系统包管理器,需要实现 `PackageManager` trait。
|
||||
|
||||
```rust
|
||||
trait PackageManager {
|
||||
fn name(&self) -> &str;
|
||||
fn install(&self, packages: &[&str]) -> Result<()>;
|
||||
fn update(&self) -> Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
参考 `workshop-baker/src/engine/pm/pacman.rs` 中的实现。步骤如下:
|
||||
|
||||
1. 在 `workshop-baker/src/engine/pm/` 下创建新文件(如 `yum.rs`)。
|
||||
2. 实现 `PackageManager` trait 的所有方法。
|
||||
3. 在 `pm/mod.rs` 中注册新变体到 `PackageManagerEnum`。
|
||||
4. 在配置解析中增加对应的匹配分支。
|
||||
|
||||
## 示例:pacman 实现
|
||||
|
||||
```rust
|
||||
pub struct PacmanManager;
|
||||
|
||||
impl PackageManager for PacmanManager {
|
||||
fn name(&self) -> &str { "pacman" }
|
||||
|
||||
fn install(&self, packages: &[&str]) -> Result<()> {
|
||||
let args = vec!["-S", "--noconfirm"].iter()
|
||||
.chain(packages.iter())
|
||||
.collect::<Vec<_>>();
|
||||
Command::new("pacman").args(&args).status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update(&self) -> Result<()> {
|
||||
Command::new("pacman").args(["-Syu", "--noconfirm"]).status()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,87 @@
|
||||
# 架构
|
||||
|
||||
## 整体架构
|
||||
|
||||
当前活跃维护的 crate:
|
||||
|
||||
- **workshop-baker** 是核心编排器,负责流水线的调度与执行,提供 CLI 和 Daemon 两种运行模式。
|
||||
- **workshop-vault** 封装 HashiCorp Vault 客户端,提供密钥的读写与引用解析。
|
||||
- **workshop-cert** 负责生成自签名 TLS 证书。
|
||||
- **workshop-deviceid** 提供设备唯一标识,用于多节点环境下的节点识别。
|
||||
|
||||
其余模块(如 workshop-agent、workshop-llm-detector、workshop-pipeline 等)已归档至 `archived/` 目录,不再活跃维护。
|
||||
|
||||
## 数据流
|
||||
|
||||
流水线分为三个阶段,顺序执行:
|
||||
|
||||
1. **Prebake(环境准备)**:根据 prebake.yml 配置创建构建环境,支持 Docker、Firecracker 和 Bare Metal 三种模式。包括包安装、用户创建、安全降权等。
|
||||
2. **Bake(脚本执行)**:加载 bake.sh 脚本,解析 `# @decorator` 注解,进行拓扑排序后按依赖顺序执行构建步骤。
|
||||
3. **Finalize(后处理)**:执行打包、部署、通知等收尾工作,通过插件系统完成邮件发送、Webhook 回调等操作。
|
||||
|
||||
三个阶段通过 `ExecutionContext` 串联,上下文信息在阶段间传递。
|
||||
|
||||
## 核心模块详解
|
||||
|
||||
### Engine
|
||||
|
||||
Engine 是 Bake 阶段的核心执行引擎,主要包含:
|
||||
|
||||
- **Executor**:负责脚本的执行,管理子进程的生命周期和输出捕获。
|
||||
- **PackageManager**:包管理抽象 trait,支持 pacman、apt 等多种包管理器的统一调用。
|
||||
|
||||
### Bake
|
||||
|
||||
Bake 模块处理构建脚本解析和调度:
|
||||
|
||||
- **Parser**:解析 bake.sh 中的 `# @decorator` 注解,提取超时、重试、并行等指令。
|
||||
- **Schedule**:基于依赖关系对构建步骤进行拓扑排序,支持并行执行无依赖的步骤。
|
||||
- **Builder**:使用模板引擎渲染构建脚本,支持变量替换和条件逻辑。
|
||||
|
||||
### Prebake
|
||||
|
||||
Prebake 模块负责构建环境准备:
|
||||
|
||||
- **Config**:解析 prebake.yml,提取环境类型(Docker/Firecracker/Bare Metal)和配置项。
|
||||
- **Bootstrap**:生成环境启动脚本,包括系统初始化和工具安装。
|
||||
- **Security**:处理权限降级,创建构建用户(默认 vulcan)并设置目录权限。
|
||||
|
||||
### Finalize
|
||||
|
||||
Finalize 模块完成后处理工作:
|
||||
|
||||
- **Plugin**:插件系统,支持 Shell、Dylib、Rhai 和 Internal 四种插件类型。
|
||||
- **Hook**:在流水线关键节点执行钩子函数,如构建成功后的通知发送。
|
||||
|
||||
## 类型系统
|
||||
|
||||
核心类型贯穿三个阶段:
|
||||
|
||||
- **ExecutionContext**:在 Prebake → Bake → Finalize 间传递的上下文对象,包含环境信息、变量和构建状态。
|
||||
- **ResourceLimits**:定义 CPU、内存、磁盘等资源限制,用于 CgroupManager 配置。
|
||||
- **BuildStatus**:表示构建状态(Pending/Running/Success/Failed/Cancelled),贯穿整个生命周期。
|
||||
|
||||
## 插件架构
|
||||
|
||||
插件系统基于 `Plugin` trait 和 `AsyncPluginFn` 类型别名:
|
||||
|
||||
```rust
|
||||
trait Plugin {
|
||||
async fn execute(&self, ctx: &ExecutionContext) -> Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
### 内部插件
|
||||
|
||||
内部插件随 baker 编译,直接调用 Rust 代码:
|
||||
|
||||
- **Mail**:基于 SMTP 发送构建通知邮件,支持 minijinja 模板。
|
||||
- **Webhook**:向外部服务发送 HTTP 回调。
|
||||
|
||||
### 外部插件
|
||||
|
||||
外部插件运行在 baker 进程之外:
|
||||
|
||||
- **Shell**:可执行脚本,通过子进程调用,需提供 `manifest.yml` 描述文件。
|
||||
- **Dylib**:动态链接库,通过 `dlopen` 加载并调用导出函数。
|
||||
- **Rhai**:使用 Rhai 脚本引擎执行 `.rhai` 脚本文件。
|
||||
@@ -0,0 +1,30 @@
|
||||
# 代码规范
|
||||
|
||||
## 命名约定
|
||||
|
||||
| 类型 | 风格 | 示例 |
|
||||
|------|------|------|
|
||||
| 文件/模块 | snake_case | `engine.rs`, `pm/` |
|
||||
| 类型/结构体/枚举 | PascalCase | `PipelineConfig`, `BuildStatus` |
|
||||
| 常量 | SCREAMING_SNAKE_CASE | `MAX_RETRIES`, `DEFAULT_TIMEOUT` |
|
||||
| 函数/变量 | snake_case | `parse_decorator`, `build_id` |
|
||||
|
||||
## 导入顺序
|
||||
|
||||
按以下顺序组织 `use` 语句,每组之间空一行:
|
||||
|
||||
1. `std` 标准库
|
||||
2. 外部 crate
|
||||
3. `crate::` 内部模块
|
||||
|
||||
```rust
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::bake::Parser;
|
||||
use crate::engine::Executor;
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# 提交规范
|
||||
|
||||
提交信息使用类型前缀:
|
||||
|
||||
| 前缀 | 说明 |
|
||||
|------|------|
|
||||
| `feat` | 新功能 |
|
||||
| `fix` | Bug 修复 |
|
||||
| `docs` | 文档变更 |
|
||||
| `test` | 测试相关 |
|
||||
| `refactor` | 重构 |
|
||||
| `style` | 代码风格(不影响功能) |
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
feat(baker): add timeout support for build steps
|
||||
fix(agent): fix TLS certificate reload on SIGHUP
|
||||
docs: update contributing guide
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
# 自定义 Decorator
|
||||
|
||||
在 bake.sh 脚本中,`# @decorator` 注解用于控制构建行为。添加自定义 Decorator 的步骤:
|
||||
|
||||
1. 在 `workshop-baker/src/bake/decorator.rs` 中的 `Decorator` enum 添加新变体:
|
||||
|
||||
```rust
|
||||
pub enum Decorator {
|
||||
Timeout { ms: u64 },
|
||||
Retry { count: u32 },
|
||||
Parallel,
|
||||
Fallible,
|
||||
MyCustom { value: String }, // 新增
|
||||
}
|
||||
```
|
||||
|
||||
2. 在 `parse_decorator` 函数的 match 中添加解析逻辑:
|
||||
|
||||
```rust
|
||||
fn parse_decorator(line: &str) -> Option<Decorator> {
|
||||
match decorator_name {
|
||||
"timeout" => Some(Decorator::Timeout { ms: ... }),
|
||||
"retry" => Some(Decorator::Retry { count: ... }),
|
||||
"parallel" => Some(Decorator::Parallel),
|
||||
"fallible" => Some(Decorator::Fallible),
|
||||
"my_custom" => Some(Decorator::MyCustom { value: ... }), // 新增
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. 在调度器中处理新 Decorator 的执行逻辑。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 自定义插件
|
||||
|
||||
## Shell 插件
|
||||
|
||||
Shell 插件是最简单的外部插件形式,只需提供一个可执行脚本和 `manifest.yml`。
|
||||
|
||||
**可执行脚本** (`my-plugin.sh`):
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
echo "Running custom plugin"
|
||||
```
|
||||
|
||||
**manifest.yml 格式**:
|
||||
|
||||
```yaml
|
||||
name: my-plugin
|
||||
version: "1.0"
|
||||
type: shell
|
||||
command: ./my-plugin.sh
|
||||
timeout: 300
|
||||
env:
|
||||
MY_VAR: "hello"
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| name | 插件名称 |
|
||||
| version | 插件版本 |
|
||||
| type | 插件类型:`shell` / `dylib` / `rhai` |
|
||||
| command | 可执行文件路径(shell 类型) |
|
||||
| timeout | 超时时间(秒) |
|
||||
| env | 环境变量映射 |
|
||||
|
||||
## Dylib 插件
|
||||
|
||||
动态链接库,通过 `dlopen` 加载并调用约定符号的导出函数。需确保 ABI 兼容。
|
||||
|
||||
## Rhai 插件
|
||||
|
||||
使用 Rhai 脚本引擎执行 `.rhai` 文件。当前为 `todo!()` 占位,尚未实现。
|
||||
@@ -0,0 +1,412 @@
|
||||
# 设计决策
|
||||
|
||||
本文档记录了 HoneyBiscuitWorkshop 架构演进过程中的关键设计决策及其背后的思考。
|
||||
|
||||
> **注意**:本文档面向开发者,记录的是**目标架构**的设计方向,而非当前代码的实现现状。
|
||||
> 当前 bakerd 尚未完全实现(`daemon.rs` 为 `todo!()`),bare mode 仅用于开发调试。
|
||||
|
||||
---
|
||||
|
||||
## 1. Bare Mode 与 Daemon Mode
|
||||
|
||||
### 决策
|
||||
|
||||
**Bare mode 仅用于开发调试,不承载生产语义。**
|
||||
|
||||
### 背景
|
||||
|
||||
当前代码提供两种运行入口:
|
||||
|
||||
| 模式 | 入口 | 状态 | 用途 |
|
||||
|------|------|------|------|
|
||||
| **Bare mode** | `cargo run -- prebake/bake/finalize <args>` | 可用 | 开发调试 |
|
||||
| **Daemon mode** | `cargo run --` (无子命令) | `todo!()` | 生产 |
|
||||
|
||||
### 推论
|
||||
|
||||
- Bare mode 中 `ctx.privileged` 是写死的 fake 值(`Prebake: true`, `Bake: false`),不代表生产行为
|
||||
- Bare mode 中 `os_info::get()` 读取的是 HOST 的 OS 信息,不代表环境内的 OS
|
||||
- 不要从 bare mode 的行为推导生产路径的问题
|
||||
- 不要为了"修复" bare mode 的问题而给生产设计增加不必要的抽象
|
||||
|
||||
### 代码残留
|
||||
|
||||
当前 `bake.rs` 中存在以下已在设计层面淘汰、但因 bare mode 未被清理的代码:
|
||||
|
||||
- `fn privileged()` — 从未被调用
|
||||
- `type TaskResult` — 从未被使用
|
||||
- `type TaskFn` — 从未被使用
|
||||
|
||||
这些是 `bake.rs` 废弃功能的残留,不涉及生产设计。清理它们不改变任何行为。
|
||||
|
||||
---
|
||||
|
||||
## 2. 环境模型
|
||||
|
||||
### 决策
|
||||
|
||||
**bakerd 先创建环境(容器/VM/裸机),然后 baker 在环境内执行全部三个阶段(prebake → bake → finalize)。**
|
||||
|
||||
### 架构
|
||||
|
||||
```
|
||||
bakerd
|
||||
├── prepare_environment (容器/VM/裸机) ← 先建隔离环境
|
||||
├── prebake (全在环境内执行) ← 包括 depssystem 的包安装
|
||||
├── bake (全在环境内执行) ← 构建脚本
|
||||
├── finalize (全在环境内执行) ← 部署通知
|
||||
└── cleanup
|
||||
```
|
||||
|
||||
### 推论
|
||||
|
||||
- `prepare_environment` 不是 prebake 的一个 stage——它是 prebake 的前置条件
|
||||
- `depssystem.rs` 中 `os_info::get()` 读取的应当是**环境内**的 `/etc/os-release`,逻辑正确
|
||||
- 容器镜像只需拉取(`prepare_container`),不需要在代码层面管理容器生命周期——那是 bakerd 的职责
|
||||
- 当前 `prepare_container` 返回的 `_container` 被丢弃,是因为 bakerd 尚未实现,不是设计缺陷
|
||||
|
||||
### 环境类型
|
||||
|
||||
```yaml
|
||||
env:
|
||||
type: container # Docker/Podman 容器
|
||||
# type: firecracker # Firecracker 微 VM
|
||||
# type: baremetal # 裸机直接执行
|
||||
image: ubuntu:24.04
|
||||
```
|
||||
|
||||
环境声明决定了:
|
||||
|
||||
1. 隔离级别(进程级 / VM级 / 无隔离)
|
||||
2. 操作系统和工具链(通过 `image`)
|
||||
3. 包管理器(从 OS 推导)
|
||||
|
||||
---
|
||||
|
||||
## 3. PM 选择链
|
||||
|
||||
### 决策
|
||||
|
||||
**包管理器(PM)应从环境声明中推导,而不是通过运行时探测。**
|
||||
|
||||
### 选择链
|
||||
|
||||
```
|
||||
用户声明 bakerd 确定 执行时确定
|
||||
env.image: ubuntu:24.04
|
||||
│
|
||||
▼
|
||||
容器运行时 (Docker/VM)
|
||||
│ os_info::get() 在环境内
|
||||
▼
|
||||
OS: Ubuntu 24.04
|
||||
│ 从已知映射推导
|
||||
▼
|
||||
PM: apt
|
||||
│ depssystem 使用 PM trait 操作
|
||||
▼
|
||||
apt install -y {packages}
|
||||
```
|
||||
|
||||
### 为什么淘汰 detect()
|
||||
|
||||
`pm::detect()` 的当前逻辑:
|
||||
|
||||
```rust
|
||||
pub fn detect(distro: &os_info::Info) -> Option<Box<dyn PackageManager>> {
|
||||
all_managers().into_iter().find(|pm| pm.adopt(distro))
|
||||
}
|
||||
```
|
||||
|
||||
它在解决一个**已经知道答案的问题**:
|
||||
|
||||
- 用户声明了 `image: ubuntu:24.04`
|
||||
- 容器内 `/etc/os-release` 确认了 Ubuntu
|
||||
- `detect()` 遍历所有 PM 并匹配,得出 apt
|
||||
- **结果和环境声明一致,但绕了一大圈**
|
||||
|
||||
`detect()` 的唯一价值场景:
|
||||
|
||||
- **Bare mode** 用户没配环境时,省一个配置项
|
||||
- 这在生产路径中不应出现(bakerd 总是提供环境)
|
||||
|
||||
### 推论
|
||||
|
||||
- PM trait 保留(见下节),但 `adopt()` 方法在生产路径中不需要
|
||||
- `detect()` / `adopt()` / `os_info::get()` 这条路径应当是 dev-only 的便利设施
|
||||
- Daemon 模式中 PM 的选择依据是环境声明,不是运行时探测
|
||||
- 当前 `os_info::Type` 的 58 个变体覆盖不全(仅 Pacman 可用)——这是 bare mode 的问题,不影响生产设计
|
||||
|
||||
---
|
||||
|
||||
## 4. PM Trait 的职责边界
|
||||
|
||||
### 决策
|
||||
|
||||
**PM trait 保留核心操作能力,淘汰探测分类能力。**
|
||||
|
||||
### 核心方法
|
||||
|
||||
```rust
|
||||
pub trait PackageManager {
|
||||
fn name(&self) -> &'static str;
|
||||
fn update(&self) -> Vec<Command>;
|
||||
fn install(&self, package: &[String]) -> Vec<Command>;
|
||||
fn change_mirror(&self, repo: &str, osinfo: Option<&Info>) -> Vec<Command>;
|
||||
fn add_repository(&self, repo: &Value, osinfo: Option<&Info>) -> Vec<Command>;
|
||||
}
|
||||
```
|
||||
|
||||
这四个方法是 PM-specific 的操作,具有真实复杂度:
|
||||
|
||||
| 操作 | PM 间差异 | 原因 |
|
||||
|------|-----------|------|
|
||||
| `install` | 参数完全不同 | `apt install -y` vs `pacman -S --noconfirm` |
|
||||
| `update` | 命令不同 | `apt update` vs `pacman -Sy` |
|
||||
| `change_mirror` | 配置格式不同 | sources.list vs mirrorlist vs .repo |
|
||||
| `add_repository` | 仓库机制不同 | PPA vs AUR vs COPR |
|
||||
|
||||
### 为什么镜像源和仓库在容器中仍然需要
|
||||
|
||||
容器不解决源管理问题:
|
||||
|
||||
```
|
||||
docker pull ubuntu:24.04
|
||||
# 容器内 sources.list → archive.ubuntu.com
|
||||
# 但用户在北京 → 需要 mirror.tuna.tsinghua.edu.cn
|
||||
# 这不是预置在镜像中的——源是运行态配置
|
||||
```
|
||||
|
||||
`change_mirror` 和 `add_repository` 都是**容器运行时需要**的操作,不能预置在基础镜像中。PM trait 的存在价值就在于这些操作。
|
||||
|
||||
### 淘汰的方法
|
||||
|
||||
```rust
|
||||
fn adopt(&self, distro: &Info) -> bool; // 生产路径不需要
|
||||
fn binary(&self) -> &'static str; // 从未被使用
|
||||
```
|
||||
|
||||
### 实现策略
|
||||
|
||||
在 daemon 模式下,PM 的选型由环境声明确定(`image: ubuntu:24.04` → `apt`),PM 实现仅需按需注册,不需要自述适配范围:
|
||||
|
||||
```rust
|
||||
// 不再需要 detect() + adopt()
|
||||
// PM 实例由工厂方法根据 PM 名称字符串创建
|
||||
pub fn from_name(name: &str) -> Option<Box<dyn PackageManager>> {
|
||||
match name {
|
||||
"apt" => Some(Box::new(Apt)),
|
||||
"pacman" => Some(Box::new(Pacman)),
|
||||
"dnf" => Some(Box::new(Dnf)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 发行版多态
|
||||
|
||||
### 决策
|
||||
|
||||
**流水线不应是发行版多态的。发行版差异由容器化解决。**
|
||||
|
||||
### 原因
|
||||
|
||||
流水线的执行体(bake 脚本)天然发行版绑定:
|
||||
|
||||
```bash
|
||||
# @pipeline
|
||||
function build() {
|
||||
cmake -B build -DCMAKE_INSTALL_PREFIX=/usr # Linux 路径
|
||||
make -j$(nproc)
|
||||
}
|
||||
```
|
||||
|
||||
- 换到 macOS:`/usr` 只读,路径不同
|
||||
- 换到 Windows:MSVC 而不是 GCC
|
||||
- 换到 Alpine:musl 而不是 glibc
|
||||
|
||||
这与 PM 的差异是同一类问题,但 PM 抽象不能解决它。**容器才是解决方案:**
|
||||
|
||||
```yaml
|
||||
# 在 Ubuntu 上构建
|
||||
env:
|
||||
type: container
|
||||
image: ubuntu:24.04
|
||||
```
|
||||
|
||||
```yaml
|
||||
# 在 Alpine 上构建
|
||||
env:
|
||||
type: container
|
||||
image: alpine:3.19
|
||||
```
|
||||
|
||||
### 各层面的发行版相关性
|
||||
|
||||
| 层面 | 发行版相关 | 解决方案 |
|
||||
|------|-----------|----------|
|
||||
| DepsSystem(系统包) | ✅ | 容器内 PM |
|
||||
| DepsUser(用户包管理器) | ❌ | 天然无关(cargo/npm/pip 跨发行版一致) |
|
||||
| Bake(构建脚本) | ✅ | 容器内执行 |
|
||||
| Finalize(后处理) | ❌ | 纯产物操作,无关发行版 |
|
||||
|
||||
### UPM 的特殊位置
|
||||
|
||||
用户包管理器(UPM,如 cargo、npm、pip、nix)天然跨发行版:
|
||||
|
||||
```bash
|
||||
cargo install --locked bat # Ubuntu 和 Arch 上都一样
|
||||
npm install -g typescript # Ubuntu 和 Arch 上都一样
|
||||
```
|
||||
|
||||
`depsuser.rs` 处理的正是这个层面。但 `collect_upm_sysdeps()` 需要知道系统 PM——这是 UPM 和系统 PM 之间的 bridge。在设计上,这个 bridge 的 PM 信息应来自环境声明,而非 `detect()`。
|
||||
|
||||
---
|
||||
|
||||
## 6. drop_after 安全模型
|
||||
|
||||
### 决策
|
||||
|
||||
**`drop_after` 指定流水线在哪个 stage 之后从 root 降权到 vulcan 用户。合理的默认值是 `DepsUser`。**
|
||||
|
||||
### Stage 顺序
|
||||
|
||||
```rust
|
||||
pub enum PrebakeStage {
|
||||
Init,
|
||||
Bootstrap,
|
||||
EarlyHook,
|
||||
DepsSystem, // ← 默认
|
||||
DepsUser,
|
||||
LateHook,
|
||||
Ready,
|
||||
Never, // ← 永远不降权
|
||||
}
|
||||
```
|
||||
|
||||
### 各 stage 的特权需求
|
||||
|
||||
| Stage | 需要 root | 原因 |
|
||||
|-------|-----------|------|
|
||||
| Bootstrap | ✅ | 创建系统用户(vulcan)、配置 sudo/doas |
|
||||
| EarlyHook | ❓ | 用户自定义 |
|
||||
| DepsSystem | ✅ | `apt install` 需要 root |
|
||||
| DepsUser | ❌ | `cargo install` 不需要 root |
|
||||
| LateHook | ❓ | 用户自定义 |
|
||||
| Ready / Bake | ❌ | 构建应以非特权用户运行 |
|
||||
|
||||
### 正确的配置
|
||||
|
||||
```yaml
|
||||
security:
|
||||
drop_after: "DepsUser"
|
||||
```
|
||||
|
||||
执行效果:
|
||||
|
||||
```
|
||||
Bootstrap root ← 创建用户
|
||||
EarlyHook root ← hook 以 root 运行
|
||||
DepsSystem root ← apt install -y {packages}
|
||||
DepsUser root ← cargo install {packages}
|
||||
LateHook vulcan ← 降权,用户脚本不能 rm -rf /
|
||||
Ready vulcan
|
||||
Bake vulcan ← 构建以 vulcan 运行
|
||||
```
|
||||
|
||||
### 禁止的配置
|
||||
|
||||
`drop_after` 设置为 `Bootstrap` 或 `EarlyHook` 在当前 stage 顺序下会损坏 DepsStage(需要 root 权限的 `apt install` 在降权后无法执行)。
|
||||
|
||||
**配置验证应拒绝 `drop_after < DepsUser`**(以 root 运行的 user 级包安装没有意义,但 DepsSystem 必须 root)。
|
||||
|
||||
### 特殊值 Never
|
||||
|
||||
```yaml
|
||||
security:
|
||||
drop_after: "Never" # 整条流水线以 root 运行
|
||||
```
|
||||
|
||||
适用场景:Docker 构建、内核模块编译等需要 root 权限的操作。Bootstrap 的 `user: root` 配置也会产生相同效果。
|
||||
|
||||
---
|
||||
|
||||
## 7. 当前代码中的真问题
|
||||
|
||||
以下问题是当前代码中确实存在的,与 bare/daemon 模式无关:
|
||||
|
||||
### 7.1 Apt::adopt 永远返回 false
|
||||
|
||||
```rust
|
||||
// workshop-engine/src/pm/apt.rs:20-23
|
||||
fn adopt(&self, distro: &Info) -> bool {
|
||||
return false; // ← 永远 false
|
||||
// todo!(); // ← 死代码
|
||||
}
|
||||
```
|
||||
|
||||
- `adopt()` 在 `return false` 后无法执行 `todo!()`
|
||||
- Apt 永远不会被 `detect()` 选中
|
||||
- 后果:Debian/Ubuntu 用户在 bare mode 下永远检测不到 PM
|
||||
- 修复方向:移除 return false,实现真正的 adopt 逻辑(Debian/Ubuntu/Mint/Pop 等发行版的匹配)
|
||||
|
||||
### 7.2 PM 覆盖严重不全
|
||||
|
||||
当前 `all_managers()` 仅注册了 Pacman:
|
||||
|
||||
```rust
|
||||
pub fn all_managers() -> Vec<Box<dyn PackageManager>> {
|
||||
vec![
|
||||
// Box::new(apt::Apt), // adopt 坏了
|
||||
Box::new(pacman::Pacman), // 唯一可用
|
||||
// Box::new(dnf::Dnf), // 未实现
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
os_info 3.14.0 定义了 58 种发行版类型,Pacman 仅覆盖其中 9 种(Arch 系)。其余 49 种无 PM 覆盖。
|
||||
|
||||
### 7.3 死代码
|
||||
|
||||
| 位置 | 内容 | 状态 |
|
||||
|------|------|------|
|
||||
| `bake.rs:98` | `fn privileged()` | 定义但未调用 |
|
||||
| `bake.rs:108` | `type TaskResult` | 定义但未使用 |
|
||||
| `bake.rs:110` | `type TaskFn` | 定义但未使用 |
|
||||
| `workshop-baker/src/prebake/stage/environment.rs` | `prepare_environment()` | 定义但未接入执行流 |
|
||||
|
||||
---
|
||||
|
||||
## 8. Daemon 设计原则(汇总)
|
||||
|
||||
以上讨论最终可以提炼为以下设计原则,供 bakerd 重构时参考:
|
||||
|
||||
### P1. 环境声明驱动
|
||||
|
||||
```
|
||||
用户声明 env.image → bakerd 创建环境 → 环境内确定 PM → depssystem 操作 PM
|
||||
```
|
||||
|
||||
PM 的选择权在环境声明,不在运行时探测。`detect()` 不出现在生产路径中。
|
||||
|
||||
### P2. 容器是发行版隔离的边界
|
||||
|
||||
发行版差异在容器边界被隔断。容器内是已知、确定的环境。PM trait 只负责"已知 PM 的操作"(install、update、mirror、repo),不负责"猜是什么 PM"。
|
||||
|
||||
### P3. 降权边界可配置但需校验
|
||||
|
||||
`drop_after` 从 root 降权到 vulcan,默认在 DepsUser 之后。配置验证需确保:降权不会损坏需要 root 的 stage。
|
||||
|
||||
### P4. 最小化 trait 抽象
|
||||
|
||||
PM trait 保留操作系统所需的核心方法。不在 trait 中嵌入分类功能(adopt)。分类是一个数据映射问题,不是多态问题。
|
||||
|
||||
### P5. Bare mode 是开发工具,不承载生产行为
|
||||
|
||||
Bare mode 的行为(host 上直接执行、write static privileged 等)不代表生产设计。不要为了优化 bare mode 而增加生产路径的抽象负担。
|
||||
|
||||
### P6. 天然跨发行版的部分不需要抽象
|
||||
|
||||
UPM(cargo/npm/pip/nix)跨发行版一致,不需要 PM 级别的抽象,不需要 detect,不需要 distro 感知。trait 只应用于确实有 PM-specific 差异的部分。
|
||||
@@ -0,0 +1,15 @@
|
||||
# 开发环境
|
||||
|
||||
本项目要求:
|
||||
|
||||
- **Rust 1.85+**(使用 edition 2024)
|
||||
- **Docker**(集成测试需要)
|
||||
- **mdBook**(文档构建,可选)
|
||||
|
||||
初始化开发环境:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/HoneyBiscuitWorkshop.git
|
||||
cd HoneyBiscuitWorkshop
|
||||
cargo build
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
# Engine 模块指南
|
||||
|
||||
`engine/` 是 Baker 的运行时层,负责构建脚本执行、资源隔离与包管理。
|
||||
|
||||
## 模块划分
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `executor.rs` | 脚本执行,管理子进程生命周期与输出捕获 |
|
||||
| `cgroups.rs` | Linux cgroup v2 资源限制配置(CPU、内存、IO) |
|
||||
| `pm.rs` | 系统包管理器检测与抽象 trait |
|
||||
| `pm/pacman.rs` | Pacman 包管理器实现 |
|
||||
| `upm.rs` | 用户级包管理器(Nix、Guix、Cargo、NPM 等) |
|
||||
| `repology.rs` | Repology 包名查询与版本解析 |
|
||||
| `repology/local.rs` | 本地包数据库缓存 |
|
||||
| `socket.rs` | Unix Domain Socket 通信(daemon 模式) |
|
||||
|
||||
## PackageManager Trait
|
||||
|
||||
系统包管理器的统一接口:
|
||||
|
||||
```rust
|
||||
trait PackageManager {
|
||||
fn name(&self) -> &str;
|
||||
fn install(&self, packages: &[&str]) -> Result<()>;
|
||||
fn update(&self) -> Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
新增包管理器时:
|
||||
1. 在 `pm/` 下新建文件实现 `PackageManager`
|
||||
2. 在 `pm.rs` 的 `PackageManagerEnum` 中注册变体
|
||||
3. 在 `PrebakeConfig` 解析中增加匹配分支
|
||||
|
||||
## 资源限制
|
||||
|
||||
`cgroups.rs` 通过 Linux cgroup v2 限制构建资源:
|
||||
|
||||
| 限制项 | cgroup 文件 | 配置来源 |
|
||||
|--------|-------------|---------|
|
||||
| CPU 配额 | `cpu.max` | `resources.cpu` |
|
||||
| 内存上限 | `memory.max` | `resources.memory` |
|
||||
| 磁盘 IO | `io.max` | 预留,暂未绑定 |
|
||||
|
||||
## 执行流程
|
||||
|
||||
1. `Executor` 接收渲染后的脚本路径
|
||||
2. 根据 `PrebakeConfig` 创建 cgroup 限制(如启用)
|
||||
3. `std::process::Command` 启动子进程
|
||||
4. 实时捕获 stdout/stderr
|
||||
5. 超时或完成后,收集退出码与输出
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `cgroups.rs` 仅在 Linux 上生效,非 Linux 目标编译时相关代码被条件编译排除
|
||||
- `upm.rs` 将用户包管理器调用委托给子进程,不直接管理包状态
|
||||
- `repology.rs` 通过 HTTP 查询 Repology API 将通用包名映射到发行版特定名称
|
||||
@@ -0,0 +1,60 @@
|
||||
# 错误处理模式
|
||||
|
||||
Baker 采用 `thiserror` + `anyhow` 的组合策略处理错误。
|
||||
|
||||
## 选取原则
|
||||
|
||||
| 场景 | 使用 | 原因 |
|
||||
|------|------|------|
|
||||
| 库代码(types/、bake/、engine/) | `thiserror` | 结构化错误,调用方需要 match 处理 |
|
||||
| 二进制入口(main.rs) | `anyhow` | 统一包装,快速传播 |
|
||||
| 测试代码 | `anyhow::Result` | 减少样板,失败即 panic |
|
||||
|
||||
## thiserror 示例
|
||||
|
||||
```rust
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum BakeError {
|
||||
#[error("Decorator parse error @{decorator} (line {line_num}): {reason}")]
|
||||
DecoratorParseError {
|
||||
decorator: String,
|
||||
line_num: usize,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("Circular dependency: {0:?}")]
|
||||
CircularDependency(Vec<String>),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
}
|
||||
```
|
||||
|
||||
## anyhow 示例
|
||||
|
||||
```rust
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
fn read_config(path: &str) -> Result<String> {
|
||||
std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read config: {}", path))
|
||||
}
|
||||
```
|
||||
|
||||
## 退出码映射
|
||||
|
||||
实现 `HasExitCode` trait 将错误映射到标准退出码:
|
||||
|
||||
```rust
|
||||
impl HasExitCode for BakeError {
|
||||
fn exit_code(&self) -> i32 {
|
||||
match self {
|
||||
BakeError::DecoratorParseError { .. } => EXITCODE_PARSE_ERROR,
|
||||
BakeError::IoError(_) => EXITCODE_IO_ERROR,
|
||||
_ => EXITCODE_GENERAL_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1 +1,26 @@
|
||||
what
|
||||
# 卷3 开发者手册
|
||||
|
||||
## 项目简介
|
||||
|
||||
蜜饼工坊 (HoneyBiscuitWorkshop) 是一个 Rust 编写的 CI/CD 系统,支持 LLM 辅助的流水线自动配置。
|
||||
|
||||
## 代码结构
|
||||
|
||||
当前活跃维护的 crate:
|
||||
|
||||
| Crate | 说明 |
|
||||
|-------|------|
|
||||
| workshop-baker | 主编排器 (CLI + Daemon) |
|
||||
| workshop-vault | 密钥管理客户端 |
|
||||
| workshop-cert | TLS 证书生成器 |
|
||||
| workshop-deviceid | 设备 ID 库 |
|
||||
|
||||
其余模块(如 workshop-agent、workshop-llm-detector 等)已归档至 `archived/` 目录,不再活跃维护。
|
||||
|
||||
## 阅读指南
|
||||
|
||||
本卷面向开发者,涵盖架构、扩展和贡献指南。各章节内容如下:
|
||||
|
||||
- **架构**:整体架构、数据流、核心模块与插件系统
|
||||
- **扩展**:添加包管理器、自定义插件与 Decorator、模板语法
|
||||
- **贡献指南**:开发环境、代码规范、测试与提交流程
|
||||
@@ -0,0 +1,26 @@
|
||||
# 邮件模板
|
||||
|
||||
`MailConfig` 中的 `template` 字段支持 minijinja 语法,用于自定义邮件内容:
|
||||
|
||||
```yaml
|
||||
mail:
|
||||
smtp_host: "smtp.example.com"
|
||||
smtp_port: 587
|
||||
from: "ci@example.com"
|
||||
to: ["dev@example.com"]
|
||||
template: |
|
||||
Subject: [CI] {{PROJECT_NAME}} - {{BUILD_STATUS}}
|
||||
|
||||
项目: {{PROJECT_NAME}}
|
||||
分支: {{BRANCH}}
|
||||
提交: {{COMMIT_SHA}}
|
||||
状态: {{BUILD_STATUS}}
|
||||
|
||||
{% if BUILD_STATUS == "success" %}
|
||||
构建成功!
|
||||
{% else %}
|
||||
构建失败,请检查日志。
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
邮件模板中可使用所有 ExecutionContext 提供的模板变量。
|
||||
@@ -0,0 +1,74 @@
|
||||
# 插件系统指南
|
||||
|
||||
Baker 的插件系统基于 `Plugin` trait,支持四种运行时形态。
|
||||
|
||||
## Plugin Trait
|
||||
|
||||
```rust
|
||||
trait Plugin {
|
||||
async fn execute(&self, ctx: &ExecutionContext) -> Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
所有插件接收统一的 `ExecutionContext`,包含任务 ID、构建状态、环境变量与模板变量表。
|
||||
|
||||
## 内部插件
|
||||
|
||||
随 baker 二进制编译,直接调用 Rust 代码,无额外启动开销。
|
||||
|
||||
| 插件 | 文件 | 功能 |
|
||||
|------|------|------|
|
||||
| `mail` | `finalize/plugin/internal/mail.rs` | SMTP 邮件通知,支持 minijinja 模板 |
|
||||
| `webhook` | `finalize/plugin/internal/webhook.rs` | HTTP POST 回调 |
|
||||
| `satori` | `finalize/plugin/internal/satori.rs` | Satori 集成(占位) |
|
||||
| `insitenotify` | `finalize/plugin/internal/insitenotify.rs` | 站内通知(占位) |
|
||||
|
||||
内部插件配置位于 `finalize.yml` 的 `notification` 节点下,按数字键分组:
|
||||
|
||||
```yaml
|
||||
notification:
|
||||
1:
|
||||
mail:
|
||||
to: "admin@example.com"
|
||||
2:
|
||||
webhook:
|
||||
url: "https://hooks.example.com/ci"
|
||||
```
|
||||
|
||||
## 外部插件
|
||||
|
||||
### Shell 插件
|
||||
|
||||
可执行脚本,通过子进程调用。需在同级目录提供 `manifest.yml`:
|
||||
|
||||
```yaml
|
||||
name: "my-plugin"
|
||||
type: "shell"
|
||||
entry: "run.sh"
|
||||
```
|
||||
|
||||
### Dylib 插件
|
||||
|
||||
动态链接库,通过 `dlopen` 加载并调用约定符号的导出函数。
|
||||
|
||||
### Rhai 插件
|
||||
|
||||
使用 Rhai 脚本引擎执行 `.rhai` 文件(当前为 `todo!()` 占位,尚未实现)。
|
||||
|
||||
## 插件注册流程
|
||||
|
||||
1. `finalize.yml` 中声明插件配置
|
||||
2. `FinalizeStage::Plugin` 阶段遍历配置
|
||||
3. 根据 `type` 字段实例化对应插件类型
|
||||
4. 调用 `Plugin::execute()`,传入当前 `ExecutionContext`
|
||||
|
||||
## 模板上下文
|
||||
|
||||
所有插件共享同一模板变量表,由 `ExecutionContext` 提供:
|
||||
|
||||
| 命名空间 | 可用变量 |
|
||||
|----------|---------|
|
||||
| `build` | `status`, `id`, `duration`, `url`, `status_color` |
|
||||
| `pipeline` | `name` |
|
||||
| `commit` | `hash`, `author`, `author_email`, `message` |
|
||||
| `secret` | Vault 中存储的任意密钥(通过 `{{ secret.key }}` 引用) |
|
||||
@@ -0,0 +1,11 @@
|
||||
# PR 流程
|
||||
|
||||
提交 PR 前请确保通过所有检查:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy -- -D warnings
|
||||
cargo test
|
||||
```
|
||||
|
||||
然后提交并创建 Pull Request。CI 会自动运行完整测试套件。
|
||||
@@ -0,0 +1,88 @@
|
||||
# Resource 模型
|
||||
|
||||
## 核心思想
|
||||
|
||||
整个系统只关心两件事:东西从哪来(fetch),东西到哪去(publish)。
|
||||
|
||||
fetch 方向:URL → 本地路径 → 使用。
|
||||
publish 方向:本地路径 → 变换(插件)→ URL。
|
||||
|
||||
两个方向共用同一套寻址方式,但 Resource 中间态只存在于 fetch 方向。
|
||||
|
||||
## URL 格式
|
||||
|
||||
```
|
||||
getter::url?param1=value1¶m2=value2
|
||||
```
|
||||
|
||||
getter 显式声明意图。不是"我猜这是个 git 仓库",而是"使用者告诉我这是什么"。
|
||||
|
||||
只有三个 getter:
|
||||
|
||||
- `git` — 克隆仓库,必要时 checkout 指定版本
|
||||
- `http` / `https` — 下载文件,必要时校验 checksum
|
||||
- `file` — 复制本地文件或目录
|
||||
|
||||
版本锁定不用 `@v1.0`(`@` 在 URL 里是认证分隔符),用 `?ref=v1.0`。
|
||||
|
||||
没有 `github.com/user/repo` 看起来应该 clone 它这种黑魔法。
|
||||
没有 `.tar.gz` 看起来应该解压它的惊喜。
|
||||
URL 是 URL,行为是行为,两者用 getter:: 显式绑定。
|
||||
|
||||
## Resource 不是网络地址
|
||||
|
||||
流水线阶段(prebake / finalize / notify)只接触 Resource:一个名字 + 一个本地路径,已就绪。它们不经由网络、不解析 URL、不碰 getter。
|
||||
|
||||
所有 fetch 行为在阶段启动前完成:
|
||||
|
||||
- worker 模式:bakerd fetch → 填入 ResourceRegistry → 启动 baker
|
||||
- standalone 模式:baker 自己 fetch → 填入 ResourceRegistry → 执行阶段
|
||||
- bare 模式:不 fetch,传入的必须是 Resource
|
||||
|
||||
ResourceRegistry 是名字到路径的映射。填进去就只读了。
|
||||
|
||||
## getterurl 是独立的 crate
|
||||
|
||||
URL 格式解析 + 基本 fetch 放在 workshop-getterurl,和 baker 本身没有耦合。任何人都可以用。
|
||||
|
||||
crate 分两层:
|
||||
|
||||
```
|
||||
结构层(parser):
|
||||
"git::https://host/repo.git?ref=v1.0" → { getter: "git", url: Url, params: Params }
|
||||
|
||||
获取层(builtin getters):
|
||||
getter.fetch(url, params, dst) → local_path
|
||||
git: git clone + optional checkout
|
||||
http: HTTP GET + optional checksum
|
||||
file: std::fs::copy
|
||||
```
|
||||
|
||||
获取层是字面意义的——不解压、不推断、不猜意图。
|
||||
|
||||
## Checksum
|
||||
|
||||
checksum 作为 URL query 参数传递。三态语义:
|
||||
|
||||
- `?checksum=sha256:abc` — 必须匹配
|
||||
- `?checksum=` — 显式禁用校验
|
||||
- 无 checksum — 如果服务端返回 Content-Digest 头,校验它
|
||||
|
||||
getter 不主动校验,它只执行 URL 里指定的操作。
|
||||
|
||||
## Artifact 不经过 Resource
|
||||
|
||||
artifact 是 bake 的产物,自产生就开始被使用,不存在中间态。它的路径就是 bake 产出的本地路径,不走 ResourceRegistry。
|
||||
|
||||
artifact 的 publish 方向由插件定义行为——可能上传、可能扫描、可能只记录。插件定义最终的行为,只要是自洽的。
|
||||
|
||||
## 三种运行模式
|
||||
|
||||
```
|
||||
fetch 执行者 fetch 在阶段内
|
||||
daemon 模式 bakerd 否
|
||||
standalone baker 自身 否(阶段启动前已完成)
|
||||
bare 模式 无 否(必须传入 Resource)
|
||||
```
|
||||
|
||||
bare 模式下传入的必须是已经就绪的 Resource。外部 URL 是 daemon 或 standalone 的事,bare 不负责。
|
||||
@@ -0,0 +1,23 @@
|
||||
# 模板变量
|
||||
|
||||
Finalize 阶段使用 `{{VARIABLE}}` 语法进行模板渲染,可用变量来自 `ExecutionContext`:
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `{{PROJECT_NAME}}` | 项目名称 |
|
||||
| `{{BUILD_ID}}` | 构建ID |
|
||||
| `{{BUILD_STATUS}}` | 构建状态 |
|
||||
| `{{COMMIT_SHA}}` | 提交哈希 |
|
||||
| `{{BRANCH}}` | 分支名 |
|
||||
| `{{START_TIME}}` | 构建开始时间 |
|
||||
| `{{END_TIME}}` | 构建结束时间 |
|
||||
|
||||
模板引擎基于 minijinja,支持条件、循环等高级语法:
|
||||
|
||||
```yaml
|
||||
{% if BUILD_STATUS == "success" %}
|
||||
Build {{PROJECT_NAME}}/{{BRANCH}} succeeded!
|
||||
{% else %}
|
||||
Build {{PROJECT_NAME}}/{{BRANCH}} failed.
|
||||
{% endif %}
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
# 测试策略
|
||||
|
||||
## 测试层级
|
||||
|
||||
| 层级 | 位置 | 适用场景 | 工具 |
|
||||
|------|------|---------|------|
|
||||
| 单元测试 | `src/*/mod.rs` 内 `#[cfg(test)]` | 纯逻辑、无 IO、无副作用 | `#[test]` / `#[tokio::test]` |
|
||||
| 集成测试 | `tests/*.rs` | 跨模块协作、crate 公共 API | `cargo test --test name` |
|
||||
| 系统测试 | `tests/` (pytest) | Docker 环境、端到端 | `pytest` |
|
||||
|
||||
## 单元测试规范
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_valid_duration() {
|
||||
assert_eq!(parse_duration_to_ms("30s").unwrap(), 30_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_overflow_fails() {
|
||||
let result = parse_duration_to_ms("9999999999y");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 异步测试
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_async_execution() {
|
||||
let result = execute_command("echo hello").await.unwrap();
|
||||
assert_eq!(result.stdout, "hello\n");
|
||||
}
|
||||
```
|
||||
|
||||
## 集成测试规范
|
||||
|
||||
- 使用 `use workshop_baker::*;` 导入 crate 公共 API
|
||||
- 每个测试独立,不依赖执行顺序
|
||||
- 临时文件使用 `tempfile` crate,测试后自动清理
|
||||
|
||||
## 运行命令
|
||||
|
||||
```bash
|
||||
# 全部测试
|
||||
cargo test -p workshop-baker
|
||||
|
||||
# 仅单元测试
|
||||
cargo test --lib -p workshop-baker
|
||||
|
||||
# 仅指定集成测试
|
||||
cargo test --test types_config_integration
|
||||
|
||||
# 系统测试(需 Docker)
|
||||
pytest tests/integration/test_prebake.py -v
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
# 类型系统指南
|
||||
|
||||
`types/` 模块集中定义跨模块共享的核心类型,避免循环依赖与重复定义。
|
||||
|
||||
## 设计原则
|
||||
|
||||
| 原则 | 说明 |
|
||||
|------|------|
|
||||
| 单一事实来源 | 同一概念只在 `types/` 定义一次,各模块通过 `use crate::types::` 引用 |
|
||||
| 自包含序列化 | 复杂类型自带 serde 自定义序列化/反序列化逻辑,调用方无感知 |
|
||||
| 零成本抽象 | 偏好新类型模式(如 `MemSize(u64)`)而非裸原生类型 |
|
||||
|
||||
## 核心类型速查
|
||||
|
||||
| 类型 | 文件 | 用途 |
|
||||
|------|------|------|
|
||||
| `Architecture` | `architecture.rs` | 目标架构枚举,支持 serde 单字符串或数组 |
|
||||
| `MemSize` | `memsize.rs` | 内存/磁盘大小,支持 `"1 GiB"` / `"512 MiB"` 等人类可读格式 |
|
||||
| `BuilderConfig` | `builderconfig.rs` | 构建环境配置(Docker/Firecracker/Baremetal/Custom) |
|
||||
| `CompressionMethod` | `compression.rs` | 压缩算法枚举 |
|
||||
| `CacheStrategy` / `CacheDirectory` | `cache.rs` | 缓存策略与目录配置 |
|
||||
| `CustomCommand` | `command.rs` | 自定义命令及其超时配置 |
|
||||
| `GitVersion` | `repology.rs` | Git 引用版本解析(branch/tag/commit) |
|
||||
| `RepologyEndpoint` | `repology.rs` | Repology 包查询端点配置 |
|
||||
| `BuildStatus` | `buildstatus.rs` | 流水线整体状态 |
|
||||
| `StagePhase` / `StageResult` | `buildstatus.rs` | 阶段执行阶段与结果 |
|
||||
|
||||
## Serde 定制示例
|
||||
|
||||
### 单值或数组兼容反序列化
|
||||
|
||||
`Architecture` 支持 YAML 中写 `"x86_64"` 或 `["x86_64", "aarch64"]`:
|
||||
|
||||
```rust
|
||||
fn parse_architecture<'de, D>(deserializer: D) -> Result<HashSet<Architecture>, D::Error>
|
||||
where D: serde::Deserializer<'de>
|
||||
{
|
||||
// 实现 Visitor,同时处理 visit_str 与 visit_seq
|
||||
}
|
||||
```
|
||||
|
||||
### 人类可读大小解析
|
||||
|
||||
`MemSize` 将 `"4 GiB"` 解析为底层字节数:
|
||||
|
||||
```rust
|
||||
let mem: MemSize = "4 GiB".parse()?; // MemSize(4294967296)
|
||||
```
|
||||
|
||||
## 使用约束
|
||||
|
||||
- `types/` 不依赖 `bake/`、`engine/`、`prebake/`、`finalize/` 中的任何模块
|
||||
- 新增跨模块共享类型时,应优先放入 `types/` 而非散落在业务模块中
|
||||
@@ -1,112 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# =============================================================================
|
||||
# HoneyBiscuitWorkshop System Test Runner
|
||||
# =============================================================================
|
||||
# Usage: ./runner.sh [auto|manual] [-- pytest-args...]
|
||||
#
|
||||
# Modes:
|
||||
# auto - Build, run all tests, collect reports, cleanup (default)
|
||||
# manual - Build, start container shell for interactive testing
|
||||
#
|
||||
# Examples:
|
||||
# ./runner.sh # Run all tests automatically
|
||||
# ./runner.sh auto -k test_prebake # Run only prebake tests
|
||||
# ./runner.sh manual # Drop into container shell
|
||||
# =============================================================================
|
||||
|
||||
MODE="${1:-auto}"
|
||||
shift 2>/dev/null || true
|
||||
PYTEST_ARGS="$@"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
STAGING_DIR="/tmp/hbw-system-test"
|
||||
IMAGE_NAME="hbw-system-test:latest"
|
||||
|
||||
# ---- Cleanup handler ----
|
||||
cleanup() {
|
||||
echo "[runner] Cleaning up..."
|
||||
docker rm -f hbw-system-test-container 2>/dev/null || true
|
||||
rm -rf "$STAGING_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ---- Step 1: Build the binary ----
|
||||
build_binary() {
|
||||
echo "[runner] Building workshop-baker (release)..."
|
||||
cd "$SCRIPT_DIR/workshop-baker"
|
||||
cargo build --release 2>&1
|
||||
echo "[runner] Build complete."
|
||||
}
|
||||
|
||||
# ---- Step 2: Prepare staging directory ----
|
||||
prepare_staging() {
|
||||
echo "[runner] Preparing staging directory..."
|
||||
rm -rf "$STAGING_DIR"
|
||||
mkdir -p "$STAGING_DIR"
|
||||
|
||||
# Copy binary
|
||||
cp "$SCRIPT_DIR/workshop-baker/target/release/workshop-baker" "$STAGING_DIR/"
|
||||
chmod +x "$STAGING_DIR/workshop-baker"
|
||||
|
||||
# Copy examples if they exist
|
||||
if [ -d "$SCRIPT_DIR/workshop-baker/examples" ]; then
|
||||
cp -r "$SCRIPT_DIR/workshop-baker/examples" "$STAGING_DIR/examples"
|
||||
else
|
||||
mkdir -p "$STAGING_DIR/examples"
|
||||
fi
|
||||
|
||||
# Copy llm.env if it exists
|
||||
if [ -f "$SCRIPT_DIR/llm.env" ]; then
|
||||
cp "$SCRIPT_DIR/llm.env" "$STAGING_DIR/llm.env"
|
||||
fi
|
||||
|
||||
echo "[runner] Staging ready at $STAGING_DIR"
|
||||
}
|
||||
|
||||
# ---- Step 3: Build Docker image ----
|
||||
build_image() {
|
||||
echo "[runner] Building Docker image: $IMAGE_NAME ..."
|
||||
docker build -t "$IMAGE_NAME" -f "$SCRIPT_DIR/tests/Dockerfile" "$SCRIPT_DIR"
|
||||
echo "[runner] Image built: $IMAGE_NAME"
|
||||
}
|
||||
|
||||
# ---- Step 4: Run container ----
|
||||
run_container() {
|
||||
local mode="$1"
|
||||
echo "[runner] Starting container in $mode mode..."
|
||||
|
||||
mkdir -p "$SCRIPT_DIR/tests/results"
|
||||
|
||||
if [ "$mode" = "manual" ]; then
|
||||
docker run -it --rm \
|
||||
--name hbw-system-test-container \
|
||||
--privileged \
|
||||
-v "$STAGING_DIR:/workshop:ro" \
|
||||
-v "$SCRIPT_DIR/tests/results:/results" \
|
||||
-e TEST_MODE=manual \
|
||||
"$IMAGE_NAME"
|
||||
else
|
||||
docker run --rm \
|
||||
--name hbw-system-test-container \
|
||||
--privileged \
|
||||
-v "$STAGING_DIR:/workshop:ro" \
|
||||
-v "$SCRIPT_DIR/tests/results:/results" \
|
||||
-e TEST_MODE=auto \
|
||||
"$IMAGE_NAME" $PYTEST_ARGS
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- Main flow ----
|
||||
echo "============================================"
|
||||
echo " HoneyBiscuitWorkshop System Test Runner"
|
||||
echo " Mode: $MODE"
|
||||
echo "============================================"
|
||||
|
||||
build_binary
|
||||
prepare_staging
|
||||
build_image
|
||||
run_container "$MODE"
|
||||
|
||||
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,175 +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):
|
||||
"""Run a command and capture output.
|
||||
|
||||
Args:
|
||||
args: Command as list of strings.
|
||||
cwd: Working directory.
|
||||
env: Environment variables (merged with os.environ).
|
||||
timeout: Timeout in seconds.
|
||||
|
||||
Returns:
|
||||
dict with keys: stdout, stderr, returncode, success
|
||||
"""
|
||||
full_env = {**os.environ}
|
||||
if env:
|
||||
full_env.update(env)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
args,
|
||||
cwd=cwd,
|
||||
env=full_env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return {
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"returncode": result.returncode,
|
||||
"success": result.returncode == 0,
|
||||
}
|
||||
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):
|
||||
"""Run workshop-baker with a subcommand.
|
||||
|
||||
Args:
|
||||
subcommand: Baker subcommand (prebake, bake, finalize).
|
||||
*args: Additional arguments to the subcommand.
|
||||
cwd: Working directory.
|
||||
env: Extra environment variables.
|
||||
timeout: Timeout in seconds.
|
||||
|
||||
Returns:
|
||||
dict with keys: stdout, stderr, returncode, success
|
||||
"""
|
||||
cmd = [
|
||||
baker_bin,
|
||||
"-u", "testuser",
|
||||
"-p", "test-pipeline",
|
||||
"-b", "test-build-001",
|
||||
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,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'"
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
workspace:
|
||||
path: "/home/vulcan/workspace"
|
||||
fallback: true
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
workspace:
|
||||
path: "/home/vulcan/workspace"
|
||||
fallback: true
|
||||
-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'"
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
workspace:
|
||||
path: "/home/vulcan/workspace"
|
||||
fallback: true
|
||||
dependencies:
|
||||
system:
|
||||
pacman:
|
||||
packages:
|
||||
- "curl"
|
||||
- "wget"
|
||||
@@ -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,56 +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:
|
||||
import os
|
||||
|
||||
cache_dir = os.path.join(os.path.dirname(__file__), ".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,34 +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 raise AssertionError with descriptive message.
|
||||
"""
|
||||
|
||||
from .user import user_exists, user_uid, user_gid, user_in_group, user_shell
|
||||
from .file import (
|
||||
file_exists,
|
||||
dir_exists,
|
||||
file_permissions,
|
||||
file_contains,
|
||||
file_owned_by,
|
||||
)
|
||||
from .package import 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",
|
||||
"dir_exists",
|
||||
"file_permissions",
|
||||
"file_contains",
|
||||
"file_owned_by",
|
||||
"package_installed",
|
||||
"package_version",
|
||||
"process_running",
|
||||
"process_count",
|
||||
]
|
||||
@@ -1,43 +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
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Package installation probes for Arch Linux (pacman)."""
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def package_installed(package_name: str) -> bool:
|
||||
"""Check if a pacman package is installed."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pacman", "-Q", package_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def package_version(package_name: str) -> str | None:
|
||||
"""Get installed version of a package, or ``None`` if not installed."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pacman", "-Q", package_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# Output format: "package_name version"
|
||||
parts = result.stdout.strip().split()
|
||||
if len(parts) >= 2:
|
||||
return parts[1]
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
@@ -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,37 +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:
|
||||
"""Get the UID of a user. Raises KeyError if user doesn't exist."""
|
||||
return pwd.getpwnam(username).pw_uid
|
||||
|
||||
|
||||
def user_gid(username: str) -> int:
|
||||
"""Get the primary GID of a user. Raises KeyError if user doesn't exist."""
|
||||
return pwd.getpwnam(username).pw_gid
|
||||
|
||||
|
||||
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:
|
||||
"""Get the login shell of a user. Raises KeyError if user doesn't exist."""
|
||||
return pwd.getpwnam(username).pw_shell
|
||||
@@ -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,129 +0,0 @@
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures", "bake")
|
||||
|
||||
|
||||
@pytest.mark.bake
|
||||
class TestBakeSimpleScript:
|
||||
|
||||
def test_simple_script_runs(self, run_baker, bake_workspace, work_dir):
|
||||
prebake_cfg = os.path.join(
|
||||
os.path.dirname(__file__), "fixtures", "prebake", "minimal.yml"
|
||||
)
|
||||
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(__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(__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(__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(__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")
|
||||
|
||||
|
||||
@pytest.mark.bake
|
||||
class TestBakeStatusFiles:
|
||||
|
||||
def test_status_written_after_bake(self, run_baker, bake_workspace, work_dir):
|
||||
prebake_cfg = os.path.join(
|
||||
os.path.dirname(__file__), "fixtures", "prebake", "minimal.yml"
|
||||
)
|
||||
script = os.path.join(FIXTURES, "simple.sh")
|
||||
bake_base = os.path.join(FIXTURES, "bake_base.sh")
|
||||
|
||||
result = run_baker(
|
||||
"bake", script,
|
||||
"--bake-base", bake_base,
|
||||
"--prebake", prebake_cfg,
|
||||
cwd=work_dir,
|
||||
timeout=60,
|
||||
)
|
||||
assert result["success"]
|
||||
assert os.path.exists("/tmp/.hbwstatus")
|
||||
@@ -1,77 +0,0 @@
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from probes import file
|
||||
from llm import judge_log, LLMClient
|
||||
|
||||
|
||||
FIXTURES = os.path.join(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
|
||||
class TestFinalizeStatus:
|
||||
|
||||
def test_finalize_writes_status(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "minimal.yml")
|
||||
result = run_baker("finalize", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"]
|
||||
assert os.path.exists("/tmp/.hbwstatus")
|
||||
|
||||
|
||||
@pytest.mark.finalize
|
||||
@pytest.mark.llm
|
||||
class TestFinalizeWithLLM:
|
||||
|
||||
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.xfail(reason="Plugin download requires network/git access", strict=False)
|
||||
def test_plugin_download(self, run_baker, work_dir):
|
||||
pass
|
||||
|
||||
@pytest.mark.xfail(reason="Artifact stage not yet implemented", strict=False)
|
||||
def test_artifact_collection(self, run_baker, work_dir):
|
||||
pass
|
||||
@@ -1,94 +0,0 @@
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from probes import user, file, package
|
||||
|
||||
|
||||
FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures", "prebake")
|
||||
|
||||
|
||||
@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") or file.dir_exists("/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")
|
||||
|
||||
@pytest.mark.xfail(reason="Sudoers not created without package manager config", strict=False)
|
||||
def test_bootstrap_creates_sudoers(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("/etc/sudoers.d/workshop")
|
||||
|
||||
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)}"
|
||||
|
||||
|
||||
@pytest.mark.prebake
|
||||
class TestPrebakeDeps:
|
||||
|
||||
@pytest.mark.xfail(reason="Package installation may fail in test environment")
|
||||
def test_pacman_packages_installed(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "with_pacman.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=120)
|
||||
assert result["success"], f"prebake failed: {result['stderr']}"
|
||||
assert package.package_installed("curl")
|
||||
assert package.package_installed("wget")
|
||||
|
||||
|
||||
@pytest.mark.prebake
|
||||
class TestPrebakeHooks:
|
||||
|
||||
@pytest.mark.xfail(reason="Late hook fails after privilege drop to vulcan (Permission denied)", 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: stdout={result['stdout']!r} stderr={result['stderr']!r}"
|
||||
assert "early hook executed" in result["stdout"] or "early hook executed" in result["stderr"]
|
||||
|
||||
@pytest.mark.xfail(reason="Late hook fails after privilege drop to vulcan (Permission denied)", 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: stdout={result['stdout']!r} stderr={result['stderr']!r}"
|
||||
assert "late hook executed" in result["stdout"] or "late hook executed" in result["stderr"]
|
||||
|
||||
|
||||
@pytest.mark.prebake
|
||||
class TestPrebakeStatus:
|
||||
|
||||
def test_status_files_written(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "bootstrap_only.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"]
|
||||
assert os.path.exists("/tmp/.hbwstatus")
|
||||
|
||||
def test_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']}"
|
||||
Generated
+107
@@ -0,0 +1,107 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "workshop-baker-params"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "workshop-baker-params"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
@@ -0,0 +1,54 @@
|
||||
use crate::apply_field;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::{ParamVal, Writer, Overridden, traits::MergeParams};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BakeParams {
|
||||
#[serde(default)] pub workspace: ParamVal<String>,
|
||||
/// Health probe interval in seconds (default 5).
|
||||
#[serde(default)] pub health_period: ParamVal<u64>,
|
||||
/// Health probe per-attempt timeout in seconds (default 10).
|
||||
#[serde(default)] pub health_timeout: ParamVal<u64>,
|
||||
/// Max health probe retries before giving up (default 12).
|
||||
#[serde(default)] pub health_max_retries: ParamVal<u32>,
|
||||
}
|
||||
|
||||
impl MergeParams for BakeParams {
|
||||
fn defaults() -> Self {
|
||||
Self {
|
||||
workspace: ParamVal::new("/workspace".into()),
|
||||
health_period: ParamVal::new(5),
|
||||
health_timeout: ParamVal::new(10),
|
||||
health_max_retries: ParamVal::new(12),
|
||||
}
|
||||
}
|
||||
fn apply(&mut self, i: Self, w: Writer, ov: &mut Overridden, p: &str) {
|
||||
apply_field!(self.workspace, i.workspace, w, ov, p, "workspace");
|
||||
apply_field!(self.health_period, i.health_period, w, ov, p, "health_period");
|
||||
apply_field!(self.health_timeout, i.health_timeout, w, ov, p, "health_timeout");
|
||||
apply_field!(self.health_max_retries, i.health_max_retries, w, ov, p, "health_max_retries");
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BakeParams { fn default() -> Self { Self::defaults() } }
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BakeSettings {
|
||||
pub workspace: String,
|
||||
pub health_period: u64,
|
||||
pub health_timeout: u64,
|
||||
pub health_max_retries: u32,
|
||||
}
|
||||
impl Default for BakeSettings { fn default() -> Self { BakeParams::defaults().into() } }
|
||||
impl From<&BakeParams> for BakeSettings {
|
||||
fn from(p: &BakeParams) -> Self {
|
||||
Self {
|
||||
workspace: p.workspace.value.clone(),
|
||||
health_period: p.health_period.value,
|
||||
health_timeout: p.health_timeout.value,
|
||||
health_max_retries: p.health_max_retries.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<BakeParams> for BakeSettings { fn from(p: BakeParams) -> Self { (&p).into() } }
|
||||
@@ -0,0 +1,23 @@
|
||||
use crate::apply_field;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::{ParamVal, Writer, Overridden, traits::MergeParams};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FinalizeParams {
|
||||
#[serde(default)] pub artifact_retention_days: ParamVal<u32>,
|
||||
}
|
||||
|
||||
impl MergeParams for FinalizeParams {
|
||||
fn defaults() -> Self { Self { artifact_retention_days: ParamVal::new(7) } }
|
||||
fn apply(&mut self, i: Self, w: Writer, ov: &mut Overridden, p: &str) {
|
||||
apply_field!(self.artifact_retention_days, i.artifact_retention_days, w, ov, p, "artifact_retention_days");
|
||||
}
|
||||
}
|
||||
impl Default for FinalizeParams { fn default() -> Self { Self::defaults() } }
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FinalizeSettings { pub artifact_retention_days: u32 }
|
||||
impl Default for FinalizeSettings { fn default() -> Self { FinalizeParams::defaults().into() } }
|
||||
impl From<&FinalizeParams> for FinalizeSettings { fn from(p: &FinalizeParams) -> Self { Self { artifact_retention_days: p.artifact_retention_days.value } } }
|
||||
impl From<FinalizeParams> for FinalizeSettings { fn from(p: FinalizeParams) -> Self { (&p).into() } }
|
||||
@@ -0,0 +1,168 @@
|
||||
pub mod notification;
|
||||
pub mod prebake;
|
||||
pub mod bake;
|
||||
pub mod finalize;
|
||||
pub mod writer;
|
||||
pub mod param;
|
||||
pub mod traits;
|
||||
|
||||
pub use notification::{NotificationParams, NotificationSettings};
|
||||
pub use prebake::{PrebakeParams, PrebakeSettings};
|
||||
pub use bake::{BakeParams, BakeSettings};
|
||||
pub use finalize::{FinalizeParams, FinalizeSettings};
|
||||
pub use writer::Writer;
|
||||
pub use param::ParamVal;
|
||||
pub use traits::MergeParams;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Root parameter tree. Each component gets its own subtree.
|
||||
/// Used for both IPC (Serialized to JSON) and in-process merging.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Params {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub notification: Option<NotificationParams>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prebake: Option<PrebakeParams>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bake: Option<BakeParams>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub finalize: Option<FinalizeParams>,
|
||||
}
|
||||
|
||||
/// Apply and merge helpers.
|
||||
impl Params {
|
||||
/// Merge a layer from a specific writer into the current params.
|
||||
/// Returns entries that were overridden (for debug).
|
||||
pub fn apply(&mut self, incoming: Params, writer: Writer) -> Overridden {
|
||||
let mut ov = Overridden::new();
|
||||
if let Some(l) = incoming.notification {
|
||||
if let Some(ref mut b) = self.notification {
|
||||
b.apply(l, writer, &mut ov, "notification.");
|
||||
} else {
|
||||
self.notification = Some(l);
|
||||
}
|
||||
}
|
||||
if let Some(l) = incoming.prebake {
|
||||
if let Some(ref mut b) = self.prebake {
|
||||
b.apply(l, writer, &mut ov, "prebake.");
|
||||
} else {
|
||||
self.prebake = Some(l);
|
||||
}
|
||||
}
|
||||
if let Some(l) = incoming.bake {
|
||||
if let Some(ref mut b) = self.bake {
|
||||
b.apply(l, writer, &mut ov, "bake.");
|
||||
} else {
|
||||
self.bake = Some(l);
|
||||
}
|
||||
}
|
||||
if let Some(l) = incoming.finalize {
|
||||
if let Some(ref mut b) = self.finalize {
|
||||
b.apply(l, writer, &mut ov, "finalize.");
|
||||
} else {
|
||||
self.finalize = Some(l);
|
||||
}
|
||||
}
|
||||
ov
|
||||
}
|
||||
|
||||
/// Convert to baker-consumable settings. All params guaranteed to have values.
|
||||
pub fn to_settings(&self) -> Result<AllSettings, String> {
|
||||
Ok(AllSettings {
|
||||
notification: self.notification.as_ref().map(NotificationSettings::from).unwrap_or_default(),
|
||||
prebake: self.prebake.as_ref().map(PrebakeSettings::from).unwrap_or_default(),
|
||||
bake: self.bake.as_ref().map(BakeSettings::from).unwrap_or_default(),
|
||||
finalize: self.finalize.as_ref().map(FinalizeSettings::from).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain-value settings for baker consumption. No metadata, no Option.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AllSettings {
|
||||
pub notification: NotificationSettings,
|
||||
pub prebake: PrebakeSettings,
|
||||
pub bake: BakeSettings,
|
||||
pub finalize: FinalizeSettings,
|
||||
}
|
||||
|
||||
/// Debug — values that were overridden during merge, keyed by dot-path.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Overridden {
|
||||
pub entries: std::collections::HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Overridden {
|
||||
pub fn new() -> Self { Self { entries: HashMap::new() } }
|
||||
pub fn is_empty(&self) -> bool { self.entries.is_empty() }
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_empty_params_serializes_empty() {
|
||||
let p = Params::default();
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
assert_eq!(json, "{}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_notification_params_serialize() {
|
||||
let mut p = Params::default();
|
||||
p.notification = Some(NotificationParams::defaults());
|
||||
let json = serde_json::to_string_pretty(&p).unwrap();
|
||||
assert!(json.contains("max_lives"));
|
||||
assert!(json.contains("3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_courier_overrides_default() {
|
||||
let mut base = Params::default();
|
||||
base.notification = Some(NotificationParams::defaults());
|
||||
let incoming = Params {
|
||||
notification: Some(NotificationParams {
|
||||
max_lives: ParamVal::with(5, Writer::Courier),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let ov = base.apply(incoming, Writer::Courier);
|
||||
let s = base.to_settings().unwrap();
|
||||
assert_eq!(s.notification.max_lives, 5);
|
||||
assert!(ov.is_empty()); // default had no writer, not an "override"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_upstream_wins() {
|
||||
let mut base = Params::default();
|
||||
base.notification = Some(NotificationParams {
|
||||
max_lives: ParamVal::with(3, Writer::Base),
|
||||
..Default::default()
|
||||
});
|
||||
let incoming = Params {
|
||||
notification: Some(NotificationParams {
|
||||
max_lives: ParamVal::with(5, Writer::Courier),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let ov = base.apply(incoming, Writer::Courier);
|
||||
let s = base.to_settings().unwrap();
|
||||
// base(3) > courier(2) — rejected, base stays
|
||||
assert_eq!(s.notification.max_lives, 3);
|
||||
assert!(ov.is_empty()); // nothing overridden
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deny_unknown_fields() {
|
||||
let bad = r#"{"max_lives":{"value":5},"ghost_param":{"value":1}}"#;
|
||||
let result: Result<NotificationParams, _> = serde_json::from_str(bad);
|
||||
assert!(result.is_err()); // unknown field should be denied
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use crate::apply_field;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::{ParamVal, Writer, Overridden, traits::MergeParams};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NotificationParams {
|
||||
#[serde(default)] pub max_lives: ParamVal<u32>,
|
||||
#[serde(default)] pub timeout_ms: ParamVal<u64>,
|
||||
#[serde(default)] pub retry_backoff: ParamVal<f64>,
|
||||
#[serde(default)] pub retry_delay_seconds: ParamVal<f64>,
|
||||
#[serde(default)] pub max_retry_delay_seconds: ParamVal<f64>,
|
||||
#[serde(default)] pub life_impact_factor: ParamVal<f64>,
|
||||
#[serde(default)] pub batch_period: ParamVal<u32>,
|
||||
#[serde(default)] pub batch_watermark: ParamVal<u32>,
|
||||
#[serde(default)] pub template_ref_depth: ParamVal<u32>,
|
||||
}
|
||||
|
||||
impl MergeParams for NotificationParams {
|
||||
fn defaults() -> Self {
|
||||
Self {
|
||||
max_lives: ParamVal::new(3), timeout_ms: ParamVal::new(30000),
|
||||
retry_backoff: ParamVal::new(2.0), retry_delay_seconds: ParamVal::new(5.0),
|
||||
max_retry_delay_seconds: ParamVal::new(300.0),
|
||||
life_impact_factor: ParamVal::new(0.0),
|
||||
batch_period: ParamVal::new(5), batch_watermark: ParamVal::new(1),
|
||||
template_ref_depth: ParamVal::new(10),
|
||||
}
|
||||
}
|
||||
fn apply(&mut self, incoming: Self, writer: Writer, ov: &mut Overridden, prefix: &str) {
|
||||
apply_field!(self.max_lives, incoming.max_lives, writer, ov, prefix, "max_lives");
|
||||
apply_field!(self.timeout_ms, incoming.timeout_ms, writer, ov, prefix, "timeout_ms");
|
||||
apply_field!(self.retry_backoff, incoming.retry_backoff, writer, ov, prefix, "retry_backoff");
|
||||
apply_field!(self.retry_delay_seconds, incoming.retry_delay_seconds, writer, ov, prefix, "retry_delay_seconds");
|
||||
apply_field!(self.max_retry_delay_seconds, incoming.max_retry_delay_seconds, writer, ov, prefix, "max_retry_delay_seconds");
|
||||
apply_field!(self.life_impact_factor, incoming.life_impact_factor, writer, ov, prefix, "life_impact_factor");
|
||||
apply_field!(self.batch_period, incoming.batch_period, writer, ov, prefix, "batch_period");
|
||||
apply_field!(self.batch_watermark, incoming.batch_watermark, writer, ov, prefix, "batch_watermark");
|
||||
apply_field!(self.template_ref_depth, incoming.template_ref_depth, writer, ov, prefix, "template_ref_depth");
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NotificationParams { fn default() -> Self { Self::defaults() } }
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NotificationSettings {
|
||||
pub max_lives: u32, pub timeout_ms: u64, pub retry_backoff: f64,
|
||||
pub retry_delay_seconds: f64, pub max_retry_delay_seconds: f64,
|
||||
pub life_impact_factor: f64,
|
||||
pub batch_period: u32, pub batch_watermark: u32, pub template_ref_depth: u32,
|
||||
}
|
||||
|
||||
impl Default for NotificationSettings { fn default() -> Self { NotificationParams::defaults().into() } }
|
||||
|
||||
impl From<&NotificationParams> for NotificationSettings {
|
||||
fn from(p: &NotificationParams) -> Self { Self {
|
||||
max_lives: p.max_lives.value, timeout_ms: p.timeout_ms.value,
|
||||
retry_backoff: p.retry_backoff.value, retry_delay_seconds: p.retry_delay_seconds.value,
|
||||
max_retry_delay_seconds: p.max_retry_delay_seconds.value,
|
||||
life_impact_factor: p.life_impact_factor.value,
|
||||
batch_period: p.batch_period.value, batch_watermark: p.batch_watermark.value,
|
||||
template_ref_depth: p.template_ref_depth.value,
|
||||
}}
|
||||
}
|
||||
impl From<NotificationParams> for NotificationSettings { fn from(p: NotificationParams) -> Self { (&p).into() } }
|
||||
@@ -0,0 +1,32 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::Writer;
|
||||
|
||||
/// A strongly-typed parameter value with writer tracking.
|
||||
///
|
||||
/// Serializes as `{ "value": 3, "writer": "base" }`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ParamVal<T> {
|
||||
pub value: T,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub writer: Option<Writer>,
|
||||
}
|
||||
|
||||
impl<T: Default> Default for ParamVal<T> {
|
||||
fn default() -> Self {
|
||||
ParamVal { value: T::default(), writer: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ParamVal<T> {
|
||||
pub fn new(value: T) -> Self {
|
||||
ParamVal { value, writer: None }
|
||||
}
|
||||
pub fn with(value: T, writer: Writer) -> Self {
|
||||
ParamVal { value, writer: Some(writer) }
|
||||
}
|
||||
/// Whether an incoming value from `incoming_writer` should override this one.
|
||||
pub fn should_override(&self, incoming: Writer) -> bool {
|
||||
let current = self.writer.map_or(0, |w| w.priority());
|
||||
incoming.priority() > current
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
pub mod network;
|
||||
pub mod cache;
|
||||
pub mod engine;
|
||||
|
||||
use crate::apply_field;
|
||||
use crate::{ParamVal, Writer, Overridden, traits::MergeParams};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PrebakeParams {
|
||||
#[serde(default)] pub bootstrap_user: ParamVal<String>,
|
||||
#[serde(default)] pub workspace: ParamVal<String>,
|
||||
#[serde(default)] pub network: network::NetworkParams,
|
||||
#[serde(default)] pub cache: cache::CacheParams,
|
||||
#[serde(default)] pub engine: engine::EngineParams,
|
||||
#[serde(default)] pub repology_endpoint: ParamVal<String>,
|
||||
}
|
||||
|
||||
impl MergeParams for PrebakeParams {
|
||||
fn defaults() -> Self {
|
||||
Self {
|
||||
bootstrap_user: ParamVal::new("vulcan".into()), workspace: ParamVal::new("/home/vulcan/workspace".into()),
|
||||
network: network::NetworkParams::defaults(), cache: cache::CacheParams::defaults(),
|
||||
engine: engine::EngineParams::defaults(),
|
||||
repology_endpoint: ParamVal::new("default".into()),
|
||||
}
|
||||
}
|
||||
fn apply(&mut self, i: Self, w: Writer, ov: &mut Overridden, p: &str) {
|
||||
apply_field!(self.bootstrap_user, i.bootstrap_user, w, ov, p, "bootstrap_user");
|
||||
apply_field!(self.workspace, i.workspace, w, ov, p, "workspace");
|
||||
apply_field!(self.repology_endpoint, i.repology_endpoint, w, ov, p, "repology_endpoint");
|
||||
self.network.apply(i.network, w, ov, &format!("{}network.", p));
|
||||
self.cache.apply(i.cache, w, ov, &format!("{}cache.", p));
|
||||
self.engine.apply(i.engine, w, ov, &format!("{}engine.", p));
|
||||
}
|
||||
}
|
||||
impl Default for PrebakeParams { fn default() -> Self { Self::defaults() } }
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PrebakeSettings {
|
||||
pub bootstrap_user: String, pub workspace: String,
|
||||
pub network: network::NetworkSettings, pub cache: cache::CacheSettings,
|
||||
pub engine: engine::EngineSettings, pub repology_endpoint: String,
|
||||
}
|
||||
impl Default for PrebakeSettings { fn default() -> Self { PrebakeParams::defaults().into() } }
|
||||
impl From<&PrebakeParams> for PrebakeSettings {
|
||||
fn from(p: &PrebakeParams) -> Self { Self {
|
||||
bootstrap_user: p.bootstrap_user.value.clone(), workspace: p.workspace.value.clone(),
|
||||
network: (&p.network).into(), cache: (&p.cache).into(), engine: (&p.engine).into(),
|
||||
repology_endpoint: p.repology_endpoint.value.clone(),
|
||||
}}
|
||||
}
|
||||
impl From<PrebakeParams> for PrebakeSettings { fn from(p: PrebakeParams) -> Self { (&p).into() } }
|
||||
@@ -0,0 +1,37 @@
|
||||
use crate::apply_field;
|
||||
use crate::{ParamVal, Writer, Overridden, traits::MergeParams};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CacheParams {
|
||||
#[serde(default)] pub strategy: ParamVal<String>,
|
||||
#[serde(default)] pub mode: ParamVal<String>,
|
||||
#[serde(default)] pub ttl_days: ParamVal<u32>,
|
||||
#[serde(default)] pub max_size_gb: ParamVal<u32>,
|
||||
#[serde(default)] pub cleanup_policy: ParamVal<String>,
|
||||
}
|
||||
impl MergeParams for CacheParams {
|
||||
fn defaults() -> Self {
|
||||
Self {
|
||||
strategy: ParamVal::new("always".into()), mode: ParamVal::new("zstd".into()),
|
||||
ttl_days: ParamVal::new(30), max_size_gb: ParamVal::new(20),
|
||||
cleanup_policy: ParamVal::new("lru".into()),
|
||||
}
|
||||
}
|
||||
fn apply(&mut self, i: Self, w: Writer, ov: &mut Overridden, p: &str) {
|
||||
apply_field!(self.strategy, i.strategy, w, ov, p, "strategy");
|
||||
apply_field!(self.mode, i.mode, w, ov, p, "mode");
|
||||
apply_field!(self.ttl_days, i.ttl_days, w, ov, p, "ttl_days");
|
||||
apply_field!(self.max_size_gb, i.max_size_gb, w, ov, p, "max_size_gb");
|
||||
apply_field!(self.cleanup_policy, i.cleanup_policy, w, ov, p, "cleanup_policy");
|
||||
}
|
||||
}
|
||||
impl Default for CacheParams { fn default() -> Self { Self::defaults() } }
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CacheSettings { pub strategy: String, pub mode: String, pub ttl_days: u32, pub max_size_gb: u32, pub cleanup_policy: String }
|
||||
impl Default for CacheSettings { fn default() -> Self { CacheParams::defaults().into() } }
|
||||
impl From<&CacheParams> for CacheSettings { fn from(p: &CacheParams) -> Self {
|
||||
Self { strategy: p.strategy.value.clone(), mode: p.mode.value.clone(), ttl_days: p.ttl_days.value, max_size_gb: p.max_size_gb.value, cleanup_policy: p.cleanup_policy.value.clone() }
|
||||
} }
|
||||
impl From<CacheParams> for CacheSettings { fn from(p: CacheParams) -> Self { (&p).into() } }
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::apply_field;
|
||||
use crate::{ParamVal, Writer, Overridden, traits::MergeParams};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EngineParams {
|
||||
#[serde(default)] pub timeout_ms: ParamVal<u64>,
|
||||
}
|
||||
impl MergeParams for EngineParams {
|
||||
fn defaults() -> Self { Self { timeout_ms: ParamVal::new(300000) } }
|
||||
fn apply(&mut self, i: Self, w: Writer, ov: &mut Overridden, p: &str) {
|
||||
apply_field!(self.timeout_ms, i.timeout_ms, w, ov, p, "timeout_ms");
|
||||
}
|
||||
}
|
||||
impl Default for EngineParams { fn default() -> Self { Self::defaults() } }
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EngineSettings { pub timeout_ms: u64 }
|
||||
impl Default for EngineSettings { fn default() -> Self { EngineParams::defaults().into() } }
|
||||
impl From<&EngineParams> for EngineSettings { fn from(p: &EngineParams) -> Self { Self { timeout_ms: p.timeout_ms.value } } }
|
||||
impl From<EngineParams> for EngineSettings { fn from(p: EngineParams) -> Self { (&p).into() } }
|
||||
@@ -0,0 +1,23 @@
|
||||
use crate::apply_field;
|
||||
use crate::{ParamVal, Writer, Overridden, traits::MergeParams};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetworkParams {
|
||||
#[serde(default)] pub enabled: ParamVal<bool>,
|
||||
#[serde(default)] pub outbound: ParamVal<bool>,
|
||||
}
|
||||
impl MergeParams for NetworkParams {
|
||||
fn defaults() -> Self { Self { enabled: ParamVal::new(true), outbound: ParamVal::new(true) } }
|
||||
fn apply(&mut self, i: Self, w: Writer, ov: &mut Overridden, p: &str) {
|
||||
apply_field!(self.enabled, i.enabled, w, ov, p, "enabled");
|
||||
apply_field!(self.outbound, i.outbound, w, ov, p, "outbound");
|
||||
}
|
||||
}
|
||||
impl Default for NetworkParams { fn default() -> Self { Self::defaults() } }
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NetworkSettings { pub enabled: bool, pub outbound: bool }
|
||||
impl Default for NetworkSettings { fn default() -> Self { NetworkParams::defaults().into() } }
|
||||
impl From<&NetworkParams> for NetworkSettings { fn from(p: &NetworkParams) -> Self { Self { enabled: p.enabled.value, outbound: p.outbound.value } } }
|
||||
impl From<NetworkParams> for NetworkSettings { fn from(p: NetworkParams) -> Self { (&p).into() } }
|
||||
@@ -0,0 +1,28 @@
|
||||
use crate::{Writer, Overridden};
|
||||
|
||||
/// A parameter tree node that can merge itself with an incoming layer.
|
||||
pub trait MergeParams: Sized {
|
||||
fn apply(&mut self, incoming: Self, writer: Writer, ov: &mut Overridden, prefix: &str);
|
||||
fn defaults() -> Self;
|
||||
}
|
||||
|
||||
/// Apply one field's merge logic.
|
||||
/// ```ignore
|
||||
/// apply_field!(self.foo, incoming.foo, writer, ov, prefix, "foo");
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! apply_field {
|
||||
($self:ident.$field:ident, $incoming:ident.$field2:ident, $writer:expr, $ov:ident, $prefix:expr, $name:expr) => {
|
||||
let old_w = $self.$field.writer;
|
||||
if $self.$field.should_override($writer) {
|
||||
let old_v = ::std::mem::replace(&mut $self.$field.value, $incoming.$field2.value);
|
||||
$self.$field.writer = ::std::option::Option::Some($writer);
|
||||
if let ::std::option::Option::Some(w) = old_w {
|
||||
$ov.entries.insert(
|
||||
format!("{}{}", $prefix, $name),
|
||||
::serde_json::json!({"value": old_v, "writer": w.as_str()}),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Writer {
|
||||
Base,
|
||||
Courier,
|
||||
Bakerd,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
/// Priority: Base=3 > Courier=2 > Bakerd=1
|
||||
pub fn priority(self) -> u8 {
|
||||
match self {
|
||||
Writer::Base => 3,
|
||||
Writer::Courier => 2,
|
||||
Writer::Bakerd => 1,
|
||||
}
|
||||
}
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Writer::Base => "base", Writer::Courier => "courier", Writer::Bakerd => "bakerd",
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Default for Writer { fn default() -> Self { Writer::Base } }
|
||||
@@ -1,3 +1,4 @@
|
||||
temp
|
||||
examples
|
||||
quicktest.sh
|
||||
_env.sh
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# WORKSHOP-BAKER CRATE KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Primary crate — the only crate with a `[[bin]]` target. Contains the `workshop-baker` binary and `workshop_baker` library. Orchestrates the full CI/CD pipeline: prebake (env setup) → bake (script execution) → finalize (plugins, cleanup) → drain notification queue. ~69 .rs source files.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
src/
|
||||
├── main.rs # Entry: default(full pipeline) | bare(dev-only) | daemon(unimplemented)
|
||||
├── lib.rs # Clap Cli/Commands/BareCommands — CLI structs in lib, not bin
|
||||
├── error.rs # CliError enum (wraps stage errors, delegates exit_code)
|
||||
├── bare.rs # Dev-only single-stage execution, ctx.privileged always false
|
||||
├── utils.rs # Cross-cutting helpers
|
||||
├── bake/ # Script parsing + DAG scheduling + execution
|
||||
│ ├── decorator.rs # @decorator(args) parsing, 81 tests — heaviest coverage in crate
|
||||
│ ├── parser.rs # Step/function extraction
|
||||
│ ├── schedule.rs # DAG build → topological sort
|
||||
│ ├── execute.rs # Per-step timeout/retry
|
||||
│ ├── builder.rs # Script assembly
|
||||
│ ├── trivial.rs # Trivial step detection (skip in dry-run)
|
||||
│ ├── constant.rs / error.rs / util.rs
|
||||
├── prebake/ # Environment provisioning
|
||||
│ ├── config.rs # PrebakeConfig deserialization
|
||||
│ ├── stage.rs + stage/{bootstrap,depssystem,depsuser,environment,hook}.rs
|
||||
│ ├── env.rs + env/{container,firecracker,baremetal,custom}.rs # Builder dispatch
|
||||
│ ├── security.rs # Privilege/sandbox checks
|
||||
│ ├── event.rs # Event sender/receiver channel
|
||||
│ ├── types.rs + types/{memsize,cache,builderconfig,architecture}.rs
|
||||
│ ├── constant.rs / error.rs
|
||||
├── finalize/ # Post-build hooks + plugins
|
||||
│ ├── config.rs # FinalizeConfig
|
||||
│ ├── stage.rs + stage/hook.rs
|
||||
│ ├── template.rs # Output templating (minijinja)
|
||||
│ ├── plugin.rs + plugin/{shell,dylib,rhai,internal}.rs
|
||||
│ │ └── plugin/internal/{dummy,mail,satori,webhook,insitenotify}.rs
|
||||
│ ├── types.rs + types/compression.rs
|
||||
│ ├── constant.rs / error.rs / event.rs
|
||||
├── notify/ # Async notification dispatch
|
||||
│ ├── queue.rs # NotificationQueue — drain on pipeline complete
|
||||
│ ├── handler.rs / rule.rs / template.rs / types.rs / util.rs / error.rs
|
||||
└── types/ # Shared types — ZERO deps on stage modules
|
||||
├── resource.rs # ResourceRegistry (populated pre-pipeline, consumed read-only)
|
||||
└── buildstatus.rs
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| CLI args + subcommands | `src/lib.rs` | Cli, Commands(Bare/Daemon), BareCommands |
|
||||
| Pipeline orchestration | `src/main.rs` | worker_main(): prebake→bake→finalize→queue.drain() |
|
||||
| Decorator syntax | `src/bake/decorator.rs` | `# @decorator` or `# @decorator(args)`, line-by-line regex |
|
||||
| Build execution | `src/bake/execute.rs` | timeout, retry, exit code capture |
|
||||
| DAG scheduling | `src/bake/schedule.rs` | Uses workshop-schedule crate |
|
||||
| Docker builder | `src/prebake/env/container.rs` | Bollard-based, buildkit support |
|
||||
| Firecracker / Bare metal | `.../firecracker.rs`, `.../baremetal.rs` | MicroVM;
|
||||
needs PIPELINE_BAREMETAL_ELEVATE |
|
||||
| Plugin dispatch | `src/finalize/plugin.rs` | Dispatches shell / dylib(stub) / rhai(stub) / internal |
|
||||
| Notification queue | `src/notify/queue.rs` | Spawned early, drained after pipeline, retry support |
|
||||
| Error type | `src/error.rs` | CliError wraps PrebakeError/BakeError/FinalizeError/anyhow |
|
||||
| Dev bare mode | `src/bare.rs` | ctx.privileged=false always; do not extrapolate to production |
|
||||
|
||||
## DEPENDENCIES
|
||||
**Internal:** workshop-engine, workshop-schedule, workshop-getterurl, workshop-baker-params. **External:** bollard (Docker), clap (CLI), axum 0.7 (HTTP), lettre 0.11 (email), minijinja (templates), cgroups-rs (limits), privdrop.
|
||||
|
||||
## CONVENTIONS (CRATE-SPECIFIC)
|
||||
- **Lint overrides** (this crate only): `dead_code=allow`, `unreachable_code=allow`, `inherent_to_string=allow`, `non_canonical_partial_ord_impl=allow`
|
||||
- **CLI structs in lib.rs, not main.rs** — enables integration tests to import and construct Cli directly.
|
||||
- **Pipeline stages return `anyhow::Result<()>`** — stages propagate with `?`; only main.rs converts to CliError.
|
||||
- **ResourceRegistry pattern** — populated before pipeline stages, consumed read-only by stages. New resource types go in `types/resource.rs`.
|
||||
- **Notification queue fire-and-forget + drain** — notify tasks spawned early, send during pipeline, drain on completion. `NotificationQueue::drain()` must be called.
|
||||
- **Trivial step detection** — steps with no external deps can be marked trivial (`trivial.rs`), skipped in dry-run.
|
||||
## ANTI-PATTERNS (THIS CRATE)
|
||||
- **Do NOT derive production behavior from bare mode** — ctx.privileged is hardcoded false.
|
||||
- **Do NOT add CLI args bypassing prebake→bake→finalize ordering** — the pipeline is linear. Parallelism lives in the DAG scheduler within bake.
|
||||
- **Do NOT import types/ from bake/, prebake/, or finalize/** — `types/` must have zero crate-internal deps.
|
||||
- **Do NOT add lint allows without justification** — the four existing overrides are intentional.
|
||||
- **Do NOT spawn blocking work on tokio runtime** — use `spawn_blocking` for CPU-heavy tasks.
|
||||
- **Decorator regex is line-by-line** — multi-line decorators need a parser rewrite, not a regex hack.
|
||||
- **Careful with `unimplemented!()`** — Daemon, Rhai/Dylib plugins, Custom builder are stubs. Prefer error over crash.
|
||||
Generated
+499
-1373
File diff suppressed because it is too large
Load Diff
+20
-14
@@ -15,11 +15,8 @@ path = "src/main.rs"
|
||||
default = []
|
||||
integration-tests = []
|
||||
|
||||
[[test]]
|
||||
name = "engine_integration"
|
||||
path = "tests/engine_integration.rs"
|
||||
|
||||
[dependencies]
|
||||
workshop-engine = { path = "../workshop-engine" }
|
||||
anyhow = "1.0.100"
|
||||
bollard = { version = "0.20.0", features = ["buildkit", "chrono", "ssh"] }
|
||||
cgroups-rs = "0.5.0"
|
||||
@@ -31,7 +28,7 @@ env_logger = "0.11.8"
|
||||
glob = "0.3.2"
|
||||
futures-util = "0.3.31"
|
||||
lazy_static = "1.5.0"
|
||||
lettre = { version = "0.11", features = ["tokio1-native-tls"] }
|
||||
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls", "rustls-tls", "builder", "smtp-transport"] }
|
||||
log = "0.4.28"
|
||||
minijinja = "2.14.0"
|
||||
regex = "1.12.2"
|
||||
@@ -44,22 +41,31 @@ tar = "0.4.44"
|
||||
tempfile = "3.24.0"
|
||||
thiserror = "2.0.17"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
topological-sort = "0.2.2"
|
||||
workshop-schedule = { path = "../workshop-schedule" }
|
||||
async-trait = "0.1.87"
|
||||
uuid = { version = "1.19.0", features = ["v4"] }
|
||||
libc = "0.2"
|
||||
os_info = "3.14.0"
|
||||
privdrop = "0.5.6"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
which = "8.0.2"
|
||||
gix = "0.81.0"
|
||||
sha2 = "0.10"
|
||||
flate2 = "1.0"
|
||||
zstd = "0.13"
|
||||
globset = "0.4.18"
|
||||
axum = "0.7"
|
||||
workshop-getterurl = { path = "../workshop-getterurl" }
|
||||
workshop-baker-params = { version = "0.1.0", path = "../workshop-baker-params" }
|
||||
|
||||
bitflags = "2"
|
||||
url = "2.5.8"
|
||||
zstd = "0.13.3"
|
||||
flate2 = "1.1.9"
|
||||
capctl = "0.2.4"
|
||||
[dev-dependencies]
|
||||
criterion = "0.5"
|
||||
|
||||
[[bench]]
|
||||
name = "parser_bench"
|
||||
harness = false
|
||||
[lints.rust]
|
||||
dead_code = "allow"
|
||||
unreachable_code = "allow"
|
||||
|
||||
[lints.clippy]
|
||||
inherent_to_string = "allow"
|
||||
non_canonical_partial_ord_impl = "allow"
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
|
||||
OUTPUT_DIR = "/tmp"
|
||||
DECORATORS = [
|
||||
"# @pipeline",
|
||||
"# @fallible",
|
||||
"# @parallel",
|
||||
"# @export",
|
||||
"# @timeout(60)",
|
||||
"# @retry(3, 5)",
|
||||
"# @if(condition)",
|
||||
"# @after(dep)",
|
||||
"# @pipe(step1, step2)",
|
||||
"# @health(/health)",
|
||||
"# @loop(10)",
|
||||
]
|
||||
FUNCTION_BODIES = [
|
||||
"echo 'Processing...';",
|
||||
"local var=$(date +%s);",
|
||||
"if [ -f /tmp/test ]; then echo 'exists'; fi;",
|
||||
"for i in $(seq 1 10); do echo $i; done;",
|
||||
"case $1 in start) echo 'starting';; stop) echo 'stopping';; esac;",
|
||||
"read -r line < /dev/stdin;",
|
||||
"export PATH=$PATH:/usr/local/bin;",
|
||||
"set -e; set -u;",
|
||||
"trap 'echo error' ERR;",
|
||||
"cd /tmp || exit 1;",
|
||||
"local result=$(grep -r 'pattern' /var/log/ 2>/dev/null);",
|
||||
'for f in /tmp/*.tmp; do rm -f "$f"; done',
|
||||
"if [[ $var -gt 100 ]]; then echo 'large'; fi;",
|
||||
'while read -r line; do echo "$line"; done < /etc/passwd;',
|
||||
'select opt in a b c; do echo "Selected $opt"; break; done;',
|
||||
]
|
||||
|
||||
|
||||
def random_string(length=8):
|
||||
return "".join(random.choices(string.ascii_letters + string.digits, k=length))
|
||||
|
||||
|
||||
def generate_function(body_lines):
|
||||
lines = []
|
||||
func_name = f"func_{random_string(6)}"
|
||||
for _ in range(random.randint(0, 3)):
|
||||
lines.append(random.choice(DECORATORS))
|
||||
lines.append(f"{func_name}() {{")
|
||||
for _ in range(body_lines):
|
||||
lines.append(" " + random.choice(FUNCTION_BODIES))
|
||||
lines.append("}")
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def generate_script(target_lines, num_functions, target_bytes):
|
||||
lines = []
|
||||
total_lines = 0
|
||||
body_per_func = max(1, (target_lines - num_functions * 3) // num_functions)
|
||||
|
||||
for _ in range(num_functions):
|
||||
func_lines = generate_function(body_per_func)
|
||||
lines.extend(func_lines)
|
||||
total_lines += len(func_lines)
|
||||
|
||||
while (
|
||||
total_lines < target_lines
|
||||
or len("\n".join(lines).encode("utf-8")) < target_bytes
|
||||
):
|
||||
lines.append(random.choice(FUNCTION_BODIES))
|
||||
total_lines += 1
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
sizes = [
|
||||
("small", 1000, 10, 50_000),
|
||||
("medium", 5000, 50, 500_000),
|
||||
("large", 10000, 100, 950_000),
|
||||
]
|
||||
for name, lines, funcs, target_bytes in sizes:
|
||||
script = generate_script(lines, funcs, target_bytes)
|
||||
filepath = os.path.join(OUTPUT_DIR, f"bench_{name}.sh")
|
||||
with open(filepath, "w") as f:
|
||||
f.write(script)
|
||||
actual_bytes = len(script.encode("utf-8"))
|
||||
actual_lines = len(script.splitlines())
|
||||
print(f"Generated {filepath}: lines={actual_lines}, bytes={actual_bytes}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,61 +0,0 @@
|
||||
use criterion::{Criterion, black_box, criterion_group, criterion_main};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn parse_large_script(c: &mut Criterion) {
|
||||
let script_path = Path::new("/tmp/bench_large.sh");
|
||||
|
||||
let script_content = fs::read_to_string(script_path)
|
||||
.expect("Failed to read benchmark script. Run `python3 benches/generate.py` first.");
|
||||
|
||||
let byte_size = script_content.len();
|
||||
let line_count = script_content.lines().count();
|
||||
|
||||
println!("Benchmark file: {} lines, {} bytes", line_count, byte_size);
|
||||
|
||||
c.bench_function("parse_script_large", |b| {
|
||||
b.iter(|| {
|
||||
let result =
|
||||
workshop_baker::bake::parser::parse_script(black_box(&script_content.clone()));
|
||||
black_box(result)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_medium_script(c: &mut Criterion) {
|
||||
let script_path = Path::new("/tmp/bench_medium.sh");
|
||||
|
||||
let script_content = fs::read_to_string(script_path)
|
||||
.expect("Failed to read benchmark script. Run `python3 benches/generate.py` first.");
|
||||
|
||||
c.bench_function("parse_script_medium", |b| {
|
||||
b.iter(|| {
|
||||
let result =
|
||||
workshop_baker::bake::parser::parse_script(black_box(&script_content.clone()));
|
||||
black_box(result)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_small_script(c: &mut Criterion) {
|
||||
let script_path = Path::new("/tmp/bench_small.sh");
|
||||
|
||||
let script_content = fs::read_to_string(script_path)
|
||||
.expect("Failed to read benchmark script. Run `python3 benches/generate.py` first.");
|
||||
|
||||
c.bench_function("parse_script_small", |b| {
|
||||
b.iter(|| {
|
||||
let result =
|
||||
workshop_baker::bake::parser::parse_script(black_box(&script_content.clone()));
|
||||
black_box(result)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
parse_small_script,
|
||||
parse_medium_script,
|
||||
parse_large_script
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -1,11 +0,0 @@
|
||||
[pytest]
|
||||
testpaths = tests/integration
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts = -v --tb=short
|
||||
markers =
|
||||
integration: marks tests as integration tests (deselect with '-m "not integration"')
|
||||
slow: marks tests as slow running
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
@@ -1,62 +0,0 @@
|
||||
# workshop-baker/src
|
||||
|
||||
**Generated:** 2026-04-22
|
||||
**Commit:** 7b3b71a
|
||||
|
||||
Baker CLI + execution engine for HoneyBiscuitWorkshop pipelines.
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
CLI tool and runtime for executing CI/CD pipelines with resource isolation, privilege dropping, and package management integration.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
src/
|
||||
├── bake.rs # Top-level bake orchestration
|
||||
├── bake/ # Script parsing and building
|
||||
│ ├── parser.rs # @decorator-aware shell parser
|
||||
│ ├── decorator.rs # @pipeline, @retry, @parallel, etc.
|
||||
│ ├── builder.rs # Script template builder
|
||||
│ └── schedule.rs # Function ordering via @after deps
|
||||
├── engine/ # Execution runtime
|
||||
│ ├── executor.rs # Script execution with cgroups
|
||||
│ ├── cgroups.rs # Linux cgroup resource limits
|
||||
│ ├── repology.rs # Package name resolution
|
||||
│ ├── pm.rs # Package manager detection
|
||||
│ └── upm.rs # User package manager (Nix/Guix)
|
||||
├── prebake/ # Build environment setup
|
||||
│ ├── config.rs # PrebakeConfig YAML schema
|
||||
│ ├── stage/ # Bootstrap, DepsSystem, DepsUser, Hooks
|
||||
│ └── security.rs # Privilege dropping logic
|
||||
├── types/ # Shared type definitions
|
||||
│ ├── repology.rs # RepologyEndpoint enum
|
||||
│ ├── builderconfig.rs
|
||||
│ ├── cache.rs
|
||||
│ └── memsize.rs
|
||||
├── monitor/ # Resource usage monitoring
|
||||
│ ├── cgroups.rs # cgroup-based usage collection
|
||||
│ └── jobobject.rs # Windows job objects (stub)
|
||||
├── daemon.rs # Unix socket daemon (TODO: refactor)
|
||||
└── lib.rs # CLI struct + module exports
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Task | Location |
|
||||
|------|----------|
|
||||
| Script parsing with @decorators | `bake/parser.rs:29` - `parse_script()` |
|
||||
| Decorator types | `bake/decorator.rs` - `Decorator` enum |
|
||||
| Build execution | `engine/executor.rs` - `Executor::execute_script()` |
|
||||
| Resource limits | `engine/types.rs:56` - `ResourceLimits` struct |
|
||||
| Prebake stages | `prebake/stage/` subdirectories |
|
||||
| Package detection | `engine/pm.rs:detection()` |
|
||||
| CLI entry | `lib.rs:26` - `cli::Cli` struct |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **ExecutionContext**: Passed through all stages, carries task_id, username, env_vars, timeout
|
||||
- **PrebakeStage**: Ordered enum controlling stage execution sequence (Bootstrap -> Ready)
|
||||
- **Decorator**: Attached to shell functions via `# @name(args)` comments above function declarations
|
||||
- **Engine**: Lightweight wrapper around cgroup manager for resource isolation
|
||||
- **Dual-mode**: CLI commands (prebake, bake, finalize) vs daemon mode via Unix socket
|
||||
+184
-105
@@ -1,35 +1,47 @@
|
||||
use crate::ExecutionContext;
|
||||
use crate::bake::parser::Function;
|
||||
use crate::types::debug::DebugFeature;
|
||||
use crate::bake::builder::RenderMode;
|
||||
use crate::bake::constant::DEFAULT_WORKSPACE;
|
||||
use crate::bake::decorator::Decorator;
|
||||
use crate::bake::error::BakeError;
|
||||
use crate::bake::util::{resolve_mode, FuncMode};
|
||||
use crate::cli::Cli;
|
||||
use crate::constant::{DEFAULT_WORKSPACE, STANDALONE_STATUS_DIR, TRIVIAL_SCRIPT_NAME};
|
||||
use crate::engine::EventSender;
|
||||
use crate::error::BakeError;
|
||||
use crate::prebake;
|
||||
use crate::prebake::PrebakeConfig;
|
||||
use crate::prebake::security::get_drop_after;
|
||||
use crate::prebake::stage::PrebakeStage;
|
||||
use crate::types::buildstatus::{StageInfo, StagePhase, StageResult, write_stage_status};
|
||||
use crate::{Engine, prebake};
|
||||
use chrono::Utc;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use workshop_baker_params::BakeSettings;
|
||||
use workshop_engine::{Engine, EventSender, ExecutionContext};
|
||||
|
||||
mod builder;
|
||||
pub mod constant;
|
||||
mod decorator;
|
||||
pub mod error;
|
||||
mod execute;
|
||||
pub mod parser;
|
||||
mod schedule;
|
||||
pub mod trivial;
|
||||
pub mod util;
|
||||
|
||||
pub async fn bake(
|
||||
script_path: &Path,
|
||||
bake_base_path: &Path,
|
||||
prebake_path: &Path,
|
||||
cli: &Cli,
|
||||
_cli: &Cli,
|
||||
ctx: &mut ExecutionContext,
|
||||
event_tx: EventSender,
|
||||
nevent_tx: Option<crate::notify::types::NotificationEventSender>,
|
||||
) -> Result<(), BakeError> {
|
||||
let script_content = std::fs::read_to_string(script_path)?;
|
||||
let prebake_content = std::fs::read_to_string(prebake_path)?;
|
||||
log::debug!("Reading script: {:?}", script_path);
|
||||
let script_content = std::fs::read_to_string(script_path).map_err(|e| {
|
||||
BakeError::IoError { path: script_path.display().to_string(), source: e }
|
||||
})?;
|
||||
log::debug!("Reading prebake.yml: {:?}", prebake_path);
|
||||
let prebake_content = std::fs::read_to_string(prebake_path).map_err(|e| {
|
||||
BakeError::IoError { path: prebake_path.display().to_string(), source: e }
|
||||
})?;
|
||||
let prebake = prebake::parse(prebake_content.as_str()).map_err(|e| {
|
||||
log::error!("Failed to parse prebake.yml: {}", e);
|
||||
log::error!("This is unexpected!");
|
||||
BakeError::YamlParseError(e)
|
||||
})?;
|
||||
let workspace = get_workspace(&prebake);
|
||||
@@ -40,85 +52,173 @@ pub async fn bake(
|
||||
.iter()
|
||||
.any(|d| matches!(d, decorator::Decorator::Pipeline))
|
||||
});
|
||||
let trivial = false;
|
||||
let use_template = true;
|
||||
// TODO: if PIP(pipeline implicit parameter).trivial is set, always use RenderMode::Off
|
||||
let render_mode = RenderMode::Full;
|
||||
|
||||
let engine = Engine::new();
|
||||
if let Some(env) = &prebake.envvars {
|
||||
if let Some(prebake_env) = &env.bake {
|
||||
// TODO: resolve from merged param layers (PIP integration)
|
||||
let bake_settings = BakeSettings::default();
|
||||
if let Some(env) = &prebake.envvars
|
||||
&& let Some(prebake_env) = &env.bake
|
||||
{
|
||||
ctx.env_vars.extend(prebake_env.clone());
|
||||
}
|
||||
|
||||
if !has_pipeline {
|
||||
log::trace!("Running trivial script");
|
||||
trivial::run_trivial(
|
||||
&script_content,
|
||||
bake_base_path,
|
||||
&workspace,
|
||||
&engine,
|
||||
ctx,
|
||||
&event_tx,
|
||||
render_mode,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
log::trace!("Running pipeline script");
|
||||
let remaining = functions.remaining_code;
|
||||
let (mut dag, func_map) = schedule::build_dag(functions.pipeline_functions)?;
|
||||
|
||||
// Pre-scan: pipe sources + decorator conflict checks.
|
||||
let mut pipe_sources: HashSet<String> = HashSet::new();
|
||||
for func in func_map.values() {
|
||||
decorator::check_conflicts(func)?;
|
||||
for decorator in &func.decorators {
|
||||
if let Decorator::Pipe(names) = decorator {
|
||||
for source in names {
|
||||
if *source != func.name {
|
||||
pipe_sources.insert(source.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_pipeline || trivial {
|
||||
let stage_start = Utc::now();
|
||||
let function = Function {
|
||||
name: TRIVIAL_SCRIPT_NAME.to_string(),
|
||||
decorators: vec![],
|
||||
body: script_content,
|
||||
};
|
||||
let script =
|
||||
builder::build_script(&function, bake_base_path, &workspace, None, use_template)?;
|
||||
let result = engine.execute_script(&script, &ctx, &event_tx).await;
|
||||
let stage_result = if result.is_ok() {
|
||||
StageResult::Success
|
||||
} else {
|
||||
StageResult::Failure
|
||||
};
|
||||
let _ = write_stage_status(
|
||||
STANDALONE_STATUS_DIR,
|
||||
StageInfo {
|
||||
name: "trivial".to_string(),
|
||||
phase: StagePhase::Bake,
|
||||
substage: "trivial".to_string(),
|
||||
result: stage_result,
|
||||
duration_ms: Utc::now()
|
||||
.signed_duration_since(stage_start)
|
||||
.num_milliseconds() as u64,
|
||||
started_at: stage_start,
|
||||
finished_at: Utc::now(),
|
||||
error_message: result.as_ref().err().map(|e| e.to_string()),
|
||||
},
|
||||
let mut stdout_cache: HashMap<String, String> = HashMap::new();
|
||||
let mut async_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
|
||||
|
||||
while !dag.is_empty() {
|
||||
let scheduler_debug = DebugFeature::from_bits_retain(ctx.debug_flags).contains(DebugFeature::Scheduler);
|
||||
if scheduler_debug {
|
||||
log::info!("[BAKE_SCHED] ready={} seq={} wild={} groups={:?}",
|
||||
dag.ready_count(),
|
||||
dag.ready_sequential().len(),
|
||||
dag.ready_wildcards().len(),
|
||||
dag.ready_groups(),
|
||||
);
|
||||
result?;
|
||||
} else {
|
||||
let sorted_functions = schedule::sort_function(functions.pipeline_functions)?;
|
||||
let remaining = functions.remaining_code;
|
||||
for func in sorted_functions {
|
||||
let stage_start = Utc::now();
|
||||
let mut modified_func = func.clone();
|
||||
modified_func.body = remaining.clone() + &func.body;
|
||||
let script = builder::build_script(
|
||||
&modified_func,
|
||||
bake_base_path,
|
||||
&workspace,
|
||||
None,
|
||||
use_template,
|
||||
)?;
|
||||
let result = engine.execute_script(&script, &ctx, &event_tx).await;
|
||||
let stage_result = if result.is_ok() {
|
||||
StageResult::Success
|
||||
} else {
|
||||
StageResult::Failure
|
||||
};
|
||||
let _ = write_stage_status(
|
||||
STANDALONE_STATUS_DIR,
|
||||
StageInfo {
|
||||
name: func.name.clone(),
|
||||
phase: StagePhase::Bake,
|
||||
substage: func.name.clone(),
|
||||
result: stage_result,
|
||||
duration_ms: Utc::now()
|
||||
.signed_duration_since(stage_start)
|
||||
.num_milliseconds() as u64,
|
||||
started_at: stage_start,
|
||||
finished_at: Utc::now(),
|
||||
error_message: result.as_ref().err().map(|e| e.to_string()),
|
||||
},
|
||||
log::debug!("[BAKE_SCHED] ready={} seq={} wild={} groups={:?}",
|
||||
dag.ready_count(),
|
||||
dag.ready_sequential().len(),
|
||||
dag.ready_wildcards().len(),
|
||||
dag.ready_groups(),
|
||||
);
|
||||
result?;
|
||||
}
|
||||
// Rule 1: sequential always goes first
|
||||
let seq_candidates = dag.ready_sequential();
|
||||
if let Some(seq_name) = seq_candidates.first() {
|
||||
let seq_name = seq_name.to_string();
|
||||
log::info!("[BAKE_SCHED] seq {}", seq_name);
|
||||
let func = &func_map[&seq_name];
|
||||
let mode = resolve_mode(func);
|
||||
|
||||
match mode {
|
||||
FuncMode::Daemon => {
|
||||
execute::execute_daemon(
|
||||
&engine, func, ctx, &event_tx,
|
||||
&workspace, &remaining, bake_base_path,
|
||||
Duration::from_secs(bake_settings.health_period),
|
||||
Duration::from_secs(bake_settings.health_timeout),
|
||||
bake_settings.health_max_retries,
|
||||
).await?;
|
||||
}
|
||||
FuncMode::Async => {
|
||||
let handle = execute::execute_async(
|
||||
func, ctx, &event_tx,
|
||||
&workspace, &remaining, bake_base_path,
|
||||
);
|
||||
async_handles.push(handle);
|
||||
}
|
||||
FuncMode::Normal => {
|
||||
let result = execute::execute_normal(
|
||||
&engine, func, ctx, &event_tx,
|
||||
&workspace, &remaining, bake_base_path, &stdout_cache,
|
||||
).await;
|
||||
match result {
|
||||
Ok(stdout) => {
|
||||
if pipe_sources.contains(&seq_name) {
|
||||
stdout_cache.insert(seq_name.clone(), stdout);
|
||||
}
|
||||
}
|
||||
Err(e) if dag.is_fallible(&seq_name) => {
|
||||
log::warn!("'{}' failed (fallible): {}", seq_name, e);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pop AFTER execution (Daemon/Async pop at start)
|
||||
if !matches!(mode, FuncMode::Daemon | FuncMode::Async) {
|
||||
dag.pop(&seq_name).map_err(|e| BakeError::IncompatibleDecorators {
|
||||
function: seq_name.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
} else {
|
||||
dag.pop(&seq_name).map_err(|e| BakeError::IncompatibleDecorators {
|
||||
function: seq_name.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// All remaining are parallel — pick a batch
|
||||
if let Some(batch) = dag.pick_batch() {
|
||||
log::info!("[BAKE_SCHED] par {:?}", batch);
|
||||
let results = execute::fire_parallel_batch(
|
||||
&batch, &engine, &func_map, ctx, &event_tx,
|
||||
&workspace, &remaining, bake_base_path, &stdout_cache,
|
||||
).await?;
|
||||
for (name, stdout) in results {
|
||||
if pipe_sources.contains(&name) {
|
||||
stdout_cache.insert(name.clone(), stdout);
|
||||
}
|
||||
if let Err(e) = dag.pop(&name) {
|
||||
log::warn!("Failed to pop '{}' from DAG: {}", name, e);
|
||||
}
|
||||
}
|
||||
} else if dag.is_stalled() {
|
||||
// Deadlock: named parallel groups exist but can't complete
|
||||
let stalled = dag.stalled_groups();
|
||||
let remaining = dag.remaining();
|
||||
return Err(BakeError::IncompatibleDecorators {
|
||||
function: remaining.join(", "),
|
||||
reason: format!(
|
||||
"parallel group(s) {:?} can never complete — blocked by @after chain",
|
||||
stalled
|
||||
),
|
||||
});
|
||||
} else {
|
||||
// Ready set is empty — cycle
|
||||
let remaining = dag.remaining();
|
||||
if !remaining.is_empty() {
|
||||
return Err(BakeError::CircularDependency(
|
||||
remaining.into_iter().map(|s| s.to_string()).collect(),
|
||||
));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all @async tasks to finish
|
||||
for handle in async_handles {
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
|
||||
crate::notify::types::send_stage_event(&nevent_tx, crate::types::buildstatus::StagePhase::Bake, "bake", crate::types::buildstatus::StageOutcome::Success).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -138,26 +238,5 @@ fn get_workspace(prebake_config: &PrebakeConfig) -> PathBuf {
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_WORKSPACE))
|
||||
}
|
||||
|
||||
fn privileged(prebake_config: &PrebakeConfig) -> bool {
|
||||
if let Some(bootstrap) = &prebake_config.bootstrap {
|
||||
if bootstrap.user == "root" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let drop_after = get_drop_after(&prebake_config.security).unwrap_or_default();
|
||||
PrebakeStage::Never == drop_after
|
||||
}
|
||||
|
||||
type TaskResult = Result<(), BakeError>;
|
||||
|
||||
type TaskFn = Box<dyn FnOnce() -> TaskResult + Send>;
|
||||
|
||||
// TODO: Learn closure and continue
|
||||
// async fn run<F>(f:F) -> TaskFn {
|
||||
// let engine = Engine::new();
|
||||
// engine.execute_script
|
||||
// }
|
||||
|
||||
// fn with_timeout<F>(f: F, duration: i32) -> TaskFn {
|
||||
|
||||
// }
|
||||
// ── AI Coding Agent marker ──
|
||||
// Code in this module PARTIALLY or FULLY utilized AI Coding Agent
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# workshop-baker/src/bake
|
||||
|
||||
**Generated:** 2026-04-22
|
||||
**Commit:** 7b3b71a
|
||||
|
||||
Script parsing and build orchestration with decorator DSL.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
bake/
|
||||
├── parser.rs # @decorator-aware shell script parser
|
||||
├── decorator.rs # Decorator enum + 100+ unit tests
|
||||
├── builder.rs # Script template assembly
|
||||
└── schedule.rs # Function ordering via @after dependencies
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Task | Location |
|
||||
|------|----------|
|
||||
| Parse script | `parser.rs:29` - `parse_script()` |
|
||||
| Decorator types | `decorator.rs` - `Decorator` enum variants |
|
||||
| Build script | `builder.rs` - template builder |
|
||||
| Schedule deps | `schedule.rs` - @after resolution |
|
||||
|
||||
## DECORATORS
|
||||
|
||||
| Decorator | Purpose |
|
||||
|-----------|---------|
|
||||
| `@pipeline` | Marks main build function |
|
||||
| `@timeout(N)` | Execution timeout seconds |
|
||||
| `@retry(N, M)` | Retry N times, M sec delay |
|
||||
| `@parallel` | Run concurrently |
|
||||
| `@fallible` | Failure doesn't fail pipeline |
|
||||
| `@after(func)` | Dependency ordering |
|
||||
| `@export` | Export vars to env |
|
||||
| `@loop(N)` | Repeat N times |
|
||||
| `@if(COND)` | Conditional execution |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **Decorator Syntax**: `# @name(args)` comment above function
|
||||
- **Parsing**: Shell grammar with decorator annotation extraction
|
||||
- **Scheduling**: DAG resolution for @after dependencies
|
||||
- **Tests**: `decorator.rs:175-829` has 100+ inline unit tests
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::types::debug::DebugFeature;
|
||||
use crate::bake::constant::PIPELINE_SKIP_ERRORCODE;
|
||||
use crate::bake::decorator::Decorator;
|
||||
use crate::bake::error::BakeError;
|
||||
use crate::bake::parser::Function;
|
||||
use crate::constant::TRIVIAL_SCRIPT_NAME;
|
||||
use minijinja::{Environment, context};
|
||||
use std::fs::Permissions;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -8,25 +10,45 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
use crate::constant::PIPELINE_SKIP_ERRORCODE;
|
||||
use crate::error::BakeError;
|
||||
/// Three-level rendering control.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RenderMode {
|
||||
/// No template, hardcoded "{{ main }}". Debug escape hatch.
|
||||
Off,
|
||||
/// Uses bake_base.sh with essential execution skeleton only.
|
||||
Minimal,
|
||||
/// Uses bake_base.sh with helpers (logs, colors, etc.).
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Template for use_template=false, which just executes the main body without any wrapping.
|
||||
impl RenderMode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
RenderMode::Off => "off",
|
||||
RenderMode::Minimal => "minimal",
|
||||
RenderMode::Full => "full",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hardcoded template for RenderMode::Off.
|
||||
const TRIVIAL_TEMPLATE: &str = "{{ main }}";
|
||||
|
||||
/// Renders a `Function` into an executable shell script using a minijinja template.
|
||||
///
|
||||
/// `render_mode` controls the template source:
|
||||
/// - `Off`: hardcoded `{{ main }}`, no bake_base file needed.
|
||||
/// - `Minimal` / `Full`: reads bake_base.sh; the template distinguishes the two
|
||||
/// via the `render_mode` context variable.
|
||||
pub fn build_script(
|
||||
function: &Function,
|
||||
bake_base: &Path,
|
||||
target_dir: &Path,
|
||||
temp_path: Option<PathBuf>,
|
||||
use_template: bool,
|
||||
render_mode: RenderMode,
|
||||
debug_flags: u32,
|
||||
) -> Result<PathBuf, BakeError> {
|
||||
let name = &function.name;
|
||||
let trivial = name == TRIVIAL_SCRIPT_NAME;
|
||||
let body = &function.body;
|
||||
let export = function
|
||||
.find_decorator(|d| matches!(d, Decorator::Export).then_some(()))
|
||||
.is_some();
|
||||
let condition = function.find_decorator(|d| {
|
||||
if let Decorator::If(content) = d {
|
||||
Some(content.clone())
|
||||
@@ -34,57 +56,59 @@ pub fn build_script(
|
||||
None
|
||||
}
|
||||
});
|
||||
let temp_path = temp_path.unwrap_or_else(|| PathBuf::from("/tmp"));
|
||||
// let temp_path = temp_path.unwrap_or_else(|| PathBuf::from("/tmp"));
|
||||
let temp_path = target_dir;
|
||||
let filename = format!("bakefn_{}.sh", name);
|
||||
let target = target_dir.join(filename);
|
||||
|
||||
let bake_base_content = if use_template {
|
||||
std::fs::read_to_string(bake_base).map_err(|e| BakeError::ScriptGenerationFailed {
|
||||
script: bake_base.display().to_string(),
|
||||
reason: e.to_string(),
|
||||
})?
|
||||
let template_content = match render_mode {
|
||||
RenderMode::Off => TRIVIAL_TEMPLATE.to_string(),
|
||||
RenderMode::Minimal | RenderMode::Full => {
|
||||
let script_debug = DebugFeature::from_bits_retain(debug_flags).contains(DebugFeature::Script);
|
||||
if script_debug {
|
||||
log::info!("Reading bake_base: {:?}", bake_base);
|
||||
} else {
|
||||
TRIVIAL_TEMPLATE.to_string()
|
||||
log::debug!("Reading bake_base: {:?}", bake_base);
|
||||
}
|
||||
std::fs::read_to_string(bake_base).map_err(|e| BakeError::IoError {
|
||||
path: bake_base.display().to_string(),
|
||||
source: e,
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
let condition_code = match condition {
|
||||
Some(content) => {
|
||||
// with some bash workaround
|
||||
format!(
|
||||
"if [ ! {} ]; then\n exit {}\nfi\n",
|
||||
"if [[ ! {} ]]; then\n exit {}\nfi\n",
|
||||
content, PIPELINE_SKIP_ERRORCODE
|
||||
)
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let mut preexport_code = String::new();
|
||||
let mut export_code = String::new();
|
||||
if !trivial {
|
||||
if export {
|
||||
preexport_code = format!("declare -xp > /dev/shm/bakeenv.before.{}\n", name);
|
||||
export_code = format!(
|
||||
"declare -xp > /dev/shm/bakeenv.after.{}\ndiff -w --new-line-format='%L' --old-line-format='' --unchanged-line-format='' /dev/shm/bakeenv.before.{} /dev/shm/bakeenv.after.{} > /dev/shm/bakeexport.{}\n",
|
||||
name, name, name, name
|
||||
);
|
||||
}
|
||||
preexport_code.push_str(format!(". /dev/shm/bakeexport.{}\n", name).as_str());
|
||||
export_code.push_str(format!("rm -f /dev/shm/bakeenv.*.{}\n", name).as_str());
|
||||
}
|
||||
// preexport/export: stubs for the future IPC-based @export.
|
||||
let preexport_code = String::new();
|
||||
let export_code = String::new();
|
||||
|
||||
let mut env = Environment::new();
|
||||
env.add_template("script", bake_base_content.as_str())?;
|
||||
let script = env.get_template("script")?;
|
||||
let rendered = if use_template {
|
||||
script.render(
|
||||
context! {main => body,condition => condition_code, preexport => preexport_code, name => name, export => export_code},
|
||||
)?
|
||||
} else {
|
||||
script.render(context! {main => body})?
|
||||
};
|
||||
env.add_template("script", &template_content)?;
|
||||
let rendered = env.get_template("script")?.render(
|
||||
context! {
|
||||
main => body,
|
||||
condition => condition_code,
|
||||
preexport => preexport_code,
|
||||
name => name,
|
||||
export => export_code,
|
||||
render_mode => render_mode.as_str(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let mut temp_file =
|
||||
NamedTempFile::new_in(&temp_path).map_err(|e| BakeError::ScriptGenerationFailed {
|
||||
script: temp_path.display().to_string(),
|
||||
action: "creating temp file".to_string(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
@@ -93,17 +117,20 @@ pub fn build_script(
|
||||
.set_permissions(Permissions::from_mode(0o700))
|
||||
.map_err(|e| BakeError::ScriptGenerationFailed {
|
||||
script: temp_path.display().to_string(),
|
||||
action: "setting permissions".to_string(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
std::fs::write(&temp_file, rendered).map_err(|e| BakeError::ScriptGenerationFailed {
|
||||
script: temp_path.display().to_string(),
|
||||
action: "writing to temp file".to_string(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
temp_file
|
||||
.persist(&target)
|
||||
.map_err(|e| BakeError::ScriptGenerationFailed {
|
||||
script: target.display().to_string(),
|
||||
action: "persisting temp file".to_string(),
|
||||
reason: e.error.to_string(),
|
||||
})?;
|
||||
Ok(target)
|
||||
@@ -114,7 +141,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_script_with_template() {
|
||||
fn test_build_script_full_mode() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let bake_base = temp_dir.path().join("bake_base.sh");
|
||||
|
||||
@@ -125,7 +152,9 @@ mod tests {
|
||||
decorators: vec![],
|
||||
body: "echo 'hello world'".to_string(),
|
||||
};
|
||||
let result = build_script(&function, &bake_base, temp_dir.path(), None, true);
|
||||
let result = build_script(
|
||||
&function, &bake_base, temp_dir.path(), RenderMode::Full, 0,
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let script_path = result.unwrap();
|
||||
@@ -134,10 +163,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_script_trivial_mode() {
|
||||
fn test_build_script_off_mode() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let function = Function {
|
||||
name: "trivial".to_string(),
|
||||
name: "test".to_string(),
|
||||
decorators: vec![],
|
||||
body: "echo 'direct output'".to_string(),
|
||||
};
|
||||
@@ -146,8 +175,8 @@ mod tests {
|
||||
&function,
|
||||
Path::new("/nonexistent"),
|
||||
temp_dir.path(),
|
||||
None,
|
||||
false,
|
||||
RenderMode::Off,
|
||||
0,
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
pub const DEFAULT_WORKSPACE: &str = "/workspace";
|
||||
pub const BAKE_SCRIPT_PATH: &str = "bake.sh";
|
||||
pub const TRIVIAL_SCRIPT_NAME: &str = "BAKE_TRIVIAL";
|
||||
pub const PIPELINE_SKIP_ERRORCODE: i32 = 233;
|
||||
@@ -3,33 +3,157 @@ use regex::Regex;
|
||||
use std::fmt::Display;
|
||||
use std::fmt::Formatter;
|
||||
|
||||
use crate::error::BakeError;
|
||||
use crate::bake::error::BakeError;
|
||||
use crate::bake::parser::Function;
|
||||
|
||||
lazy_static! {
|
||||
// "# @decorator" or "# @decorator(parameters)"
|
||||
static ref DECORATOR_REGEX: Regex = Regex::new(r"^\s*#\s+@(\w+)\s?(\((.*)\))?").unwrap()
|
||||
;
|
||||
static ref DECORATOR_REGEX: Regex = Regex::new(r"^\s*#\s+@(\w+)\s?(\((.*)\))?").unwrap();
|
||||
static ref UNEXPECTED_ARGUMENT: &'static str = "decorator does not accept arguments";
|
||||
static ref MISSING_ARGUMENT: &'static str = "decorator requires an argument";
|
||||
static ref UNKNOWN_DECORATOR: &'static str = "unknown decorator";
|
||||
static ref INVALID_ARGUMENT: &'static str = "invalid argument";
|
||||
}
|
||||
|
||||
/// Pipeline decorator representing shell script annotations in `# @name(args)` syntax.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Decorator {
|
||||
Unknown, // for general error reporting when the decorator name is not recognized
|
||||
/// Unrecognized decorator name (used for error reporting).
|
||||
Unknown,
|
||||
/// Marks a function as a main pipeline step.
|
||||
Pipeline,
|
||||
/// Conditional execution with shell condition.
|
||||
If(String),
|
||||
/// Allows the step to fail without halting the pipeline.
|
||||
Fallible,
|
||||
/// Repeat the function body N times.
|
||||
Loop(usize),
|
||||
/// Run after the specified function completes.
|
||||
After(String),
|
||||
/// Execution timeout in seconds.
|
||||
Timeout(u32),
|
||||
/// Retry N times with M second delay between attempts.
|
||||
Retry(usize, u32),
|
||||
/// Export environment variables to subsequent steps.
|
||||
Export,
|
||||
/// Chain functions via pipe with comma-separated names.
|
||||
Pipe(Vec<String>),
|
||||
Parallel,
|
||||
/// Run concurrently with other parallel functions.
|
||||
/// `None` = wildcard ("*" group, compatible with any group).
|
||||
/// `Some(name)` = restricted to the named group.
|
||||
Parallel(Option<String>),
|
||||
/// Run as a background daemon process.
|
||||
Daemon,
|
||||
/// Health check command for daemon verification (shell statement).
|
||||
Health(String),
|
||||
/// Fire-and-forget execution; DAG pop happens at start, not end.
|
||||
Async,
|
||||
}
|
||||
|
||||
/// Returns true if the decorator is a flag (position-independent, consumed after execution).
|
||||
pub fn is_flag(d: &Decorator) -> bool {
|
||||
matches!(
|
||||
d,
|
||||
Decorator::Pipeline
|
||||
| Decorator::Fallible
|
||||
| Decorator::Export
|
||||
| Decorator::If(_)
|
||||
| Decorator::After(_)
|
||||
| Decorator::Parallel(_)
|
||||
| Decorator::Health(_)
|
||||
| Decorator::Async
|
||||
| Decorator::Unknown
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns true if the decorator is a combinator (position-dependent, nestable).
|
||||
pub fn is_combinator(d: &Decorator) -> bool {
|
||||
matches!(d, Decorator::Loop(_) | Decorator::Timeout(_) | Decorator::Retry(..))
|
||||
}
|
||||
|
||||
/// Returns true if the decorator is a mode (at most one, alters what gets executed).
|
||||
pub fn is_mode(d: &Decorator) -> bool {
|
||||
matches!(d, Decorator::Pipe(_) | Decorator::Daemon | Decorator::Async)
|
||||
}
|
||||
|
||||
/// Extract the parallel group name. Returns "*" for wildcard (no group specified).
|
||||
pub fn parallel_group(d: &Decorator) -> &str {
|
||||
match d {
|
||||
Decorator::Parallel(Some(g)) => g.as_str(),
|
||||
_ => "*",
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for incompatible decorator combinations on a function.
|
||||
/// Returns `Err(IncompatibleDecorators)` on conflict, `Ok(())` otherwise.
|
||||
pub fn check_conflicts(func: &Function) -> Result<(), BakeError> {
|
||||
let has_daemon = func.decorators.iter().any(|d| matches!(d, Decorator::Daemon));
|
||||
let has_async = func.decorators.iter().any(|d| matches!(d, Decorator::Async));
|
||||
let has_pipe = func.decorators.iter().any(|d| matches!(d, Decorator::Pipe(_)));
|
||||
let has_parallel = func.decorators.iter().any(|d| matches!(d, Decorator::Parallel(_)));
|
||||
let has_health = func.decorators.iter().any(|d| matches!(d, Decorator::Health(_)));
|
||||
let has_export = func.decorators.iter().any(|d| matches!(d, Decorator::Export));
|
||||
let has_combinator = func.decorators.iter().any(|d| is_combinator(d));
|
||||
|
||||
let mode_count = [has_daemon, has_async, has_pipe].iter().filter(|&&x| x).count();
|
||||
if mode_count > 1 {
|
||||
return Err(BakeError::IncompatibleDecorators {
|
||||
function: func.name.clone(),
|
||||
reason: "@daemon, @async, and @pipe are mutually exclusive".into(),
|
||||
});
|
||||
}
|
||||
|
||||
if has_daemon {
|
||||
if has_parallel {
|
||||
return Err(BakeError::IncompatibleDecorators {
|
||||
function: func.name.clone(),
|
||||
reason: "@daemon cannot be combined with @parallel".into(),
|
||||
});
|
||||
}
|
||||
if has_combinator {
|
||||
return Err(BakeError::IncompatibleDecorators {
|
||||
function: func.name.clone(),
|
||||
reason: "@daemon cannot be combined with @loop/@retry/@timeout".into(),
|
||||
});
|
||||
}
|
||||
if has_export {
|
||||
return Err(BakeError::IncompatibleDecorators {
|
||||
function: func.name.clone(),
|
||||
reason: "@daemon cannot be combined with @export".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if has_health && !has_daemon {
|
||||
return Err(BakeError::IncompatibleDecorators {
|
||||
function: func.name.clone(),
|
||||
reason: "@health requires @daemon".into(),
|
||||
});
|
||||
}
|
||||
|
||||
if has_async {
|
||||
if has_parallel {
|
||||
return Err(BakeError::IncompatibleDecorators {
|
||||
function: func.name.clone(),
|
||||
reason: "@async cannot be combined with @parallel".into(),
|
||||
});
|
||||
}
|
||||
if has_export {
|
||||
return Err(BakeError::IncompatibleDecorators {
|
||||
function: func.name.clone(),
|
||||
reason: "@async cannot be combined with @export".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if has_pipe && has_parallel {
|
||||
return Err(BakeError::IncompatibleDecorators {
|
||||
function: func.name.clone(),
|
||||
reason: "@pipe cannot be combined with @parallel".into(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Display for Decorator {
|
||||
@@ -45,13 +169,19 @@ impl Display for Decorator {
|
||||
Decorator::Retry(count, delay) => write!(f, "@retry({}, {})", count, delay),
|
||||
Decorator::Export => write!(f, "@export"),
|
||||
Decorator::Pipe(funcs) => write!(f, "@pipe({})", funcs.join(", ")),
|
||||
Decorator::Parallel => write!(f, "@parallel"),
|
||||
Decorator::Parallel(group) => match group {
|
||||
None => write!(f, "@parallel"),
|
||||
Some(g) => write!(f, "@parallel({})", g),
|
||||
},
|
||||
Decorator::Daemon => write!(f, "@daemon"),
|
||||
Decorator::Health(check) => write!(f, "@health({})", check),
|
||||
Decorator::Async => write!(f, "@async"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a line for `# @name(args)` decorator syntax.
|
||||
/// Returns `Ok(None)` if the line is not a decorator line, `Ok(Some(Decorator))` on match.
|
||||
pub fn parse_decorator(line: &str, line_num: usize) -> Result<Option<Decorator>, BakeError> {
|
||||
let caps = match DECORATOR_REGEX.captures(line) {
|
||||
Some(c) => c,
|
||||
@@ -145,9 +275,17 @@ pub fn parse_decorator(line: &str, line_num: usize) -> Result<Option<Decorator>,
|
||||
}
|
||||
}),
|
||||
"export" => decorator_noarg(Decorator::Export),
|
||||
"parallel" => decorator_noarg(Decorator::Parallel),
|
||||
"parallel" => {
|
||||
if has_arg {
|
||||
let group = required_arg()?;
|
||||
Ok(Some(Decorator::Parallel(Some(group.to_string()))))
|
||||
} else {
|
||||
Ok(Some(Decorator::Parallel(None)))
|
||||
}
|
||||
}
|
||||
"daemon" => decorator_noarg(Decorator::Daemon),
|
||||
"health" => required_arg().map(|a| Some(Decorator::Health(a.to_string()))),
|
||||
"async" => decorator_noarg(Decorator::Async),
|
||||
"pipe" => required_arg().and_then(|a| {
|
||||
let functions: Vec<String> = a
|
||||
.split(',')
|
||||
@@ -274,7 +412,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_decorator_display_parallel() {
|
||||
assert_eq!(format!("{}", Decorator::Parallel), "@parallel");
|
||||
assert_eq!(format!("{}", Decorator::Parallel(None)), "@parallel");
|
||||
assert_eq!(
|
||||
format!("{}", Decorator::Parallel(Some("build".to_string()))),
|
||||
"@parallel(build)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -294,6 +436,11 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorator_display_async() {
|
||||
assert_eq!(format!("{}", Decorator::Async), "@async");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorator_display_unknown() {
|
||||
assert_eq!(format!("{}", Decorator::Unknown), "@<unknown>");
|
||||
@@ -324,7 +471,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_parse_decorator_parallel() {
|
||||
let result = parse_decorator("# @parallel", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Parallel));
|
||||
assert_eq!(result, Some(Decorator::Parallel(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_parallel_with_group() {
|
||||
let result = parse_decorator("# @parallel(build)", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Parallel(Some("build".to_string()))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -333,6 +486,12 @@ mod tests {
|
||||
assert_eq!(result, Some(Decorator::Daemon));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_async() {
|
||||
let result = parse_decorator("# @async", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Async));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests for parse_decorator - Valid single-argument decorators
|
||||
// =====================================================================
|
||||
@@ -560,8 +719,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_parallel_with_arg() {
|
||||
let result = parse_decorator("# @parallel(true)", 1);
|
||||
fn test_parse_decorator_parallel_empty_args() {
|
||||
let result = parse_decorator("# @parallel()", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
use thiserror::Error;
|
||||
|
||||
use workshop_engine::{
|
||||
ExecutionError,
|
||||
error::{EXITCODE_IO_ERROR, EXITCODE_PARSE_ERROR, HasExitCode},
|
||||
};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum BakeError {
|
||||
#[error("Failed to generate staged script {script} when {action}: {reason}")]
|
||||
ScriptGenerationFailed { script: String, action: String, reason: String },
|
||||
|
||||
#[error("Template error: {0}")]
|
||||
TemplateError(#[from] minijinja::Error),
|
||||
|
||||
#[error("Circular Dependency detected in bake.sh")]
|
||||
CircularDependency(Vec<String>),
|
||||
|
||||
#[error("Unknown @after of function {0}: {1}")]
|
||||
UnknownDependency(String, String),
|
||||
|
||||
#[error("IO error on {path}: {source}")]
|
||||
IoError { path: String, source: std::io::Error },
|
||||
|
||||
#[error("YAML parse error: {0}")]
|
||||
YamlParseError(#[from] serde_yaml::Error),
|
||||
|
||||
#[error("Failed to parse decorator @{decorator} (line #{line_num}): {reason}")]
|
||||
DecoratorParseError {
|
||||
decorator: String,
|
||||
line_num: usize,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("Script execution error: {0}")]
|
||||
ScriptExecutionError(#[from] ExecutionError),
|
||||
|
||||
#[error("Incompatible decorators on function '{function}': {reason}")]
|
||||
IncompatibleDecorators { function: String, reason: String },
|
||||
|
||||
#[error("Health probe failed for daemon '{command}': {error}")]
|
||||
HealthProbeFailed { command: String, error: String },
|
||||
}
|
||||
|
||||
impl HasExitCode for BakeError {
|
||||
fn exit_code(&self) -> i32 {
|
||||
match self {
|
||||
BakeError::ScriptGenerationFailed { .. } => EXITCODE_IO_ERROR,
|
||||
BakeError::TemplateError(_) => EXITCODE_PARSE_ERROR,
|
||||
BakeError::CircularDependency(_) => EXITCODE_PARSE_ERROR,
|
||||
BakeError::UnknownDependency(..) => EXITCODE_PARSE_ERROR,
|
||||
BakeError::IoError { .. } => EXITCODE_IO_ERROR,
|
||||
BakeError::YamlParseError(_) => EXITCODE_PARSE_ERROR,
|
||||
BakeError::DecoratorParseError { .. } => EXITCODE_PARSE_ERROR,
|
||||
BakeError::ScriptExecutionError(e) => e.exit_code(),
|
||||
BakeError::IncompatibleDecorators { .. } => EXITCODE_PARSE_ERROR,
|
||||
BakeError::HealthProbeFailed { .. } => EXITCODE_IO_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
use crate::types::debug::DebugFeature;
|
||||
use crate::bake::builder::{self, RenderMode};
|
||||
use crate::bake::constant::PIPELINE_SKIP_ERRORCODE;
|
||||
use crate::bake::decorator::{self, Decorator};
|
||||
use crate::bake::error::BakeError;
|
||||
use crate::bake::parser::Function;
|
||||
use crate::bake::util::{is_fallible, resolve_pipe_stdin};
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use workshop_engine::{Engine, EventSender, ExecutionContext, ExecutionError};
|
||||
|
||||
/// Execute a single function with combinator wrapping.
|
||||
/// Returns the captured stdout (for `@pipe` consumers).
|
||||
pub async fn execute_one(
|
||||
engine: &Engine,
|
||||
func: &Function,
|
||||
ctx: &mut ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
workspace: &Path,
|
||||
remaining: &str,
|
||||
bake_base: &Path,
|
||||
) -> Result<String, BakeError> {
|
||||
let combos: Vec<&Decorator> = func
|
||||
.decorators
|
||||
.iter()
|
||||
.filter(|d| decorator::is_combinator(d))
|
||||
.collect();
|
||||
|
||||
let mut modified = func.clone();
|
||||
modified.body = remaining.to_string() + &func.body;
|
||||
let script = builder::build_script(
|
||||
&modified, bake_base, workspace, RenderMode::Full, ctx.debug_flags,
|
||||
)?;
|
||||
|
||||
execute_with_combos(engine, &script, ctx, event_tx, &combos).await
|
||||
}
|
||||
|
||||
async fn execute_with_combos(
|
||||
engine: &Engine,
|
||||
script: &PathBuf,
|
||||
ctx: &mut ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
combos: &[&Decorator],
|
||||
) -> Result<String, BakeError> {
|
||||
build_combinator_chain(engine, script, ctx, event_tx, combos, 0).await
|
||||
}
|
||||
|
||||
/// Builds a combinator execution chain. Returns the captured stdout.
|
||||
/// For `@loop` + `@pipe(self)`, each iteration feeds its stdout back as
|
||||
/// the next iteration's stdin via `ctx.stdin`.
|
||||
fn build_combinator_chain<'a>(
|
||||
engine: &'a Engine,
|
||||
script: &'a PathBuf,
|
||||
ctx: &'a mut ExecutionContext,
|
||||
event_tx: &'a EventSender,
|
||||
combos: &'a [&'a Decorator],
|
||||
idx: usize,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, BakeError>> + Send + 'a>> {
|
||||
if idx >= combos.len() {
|
||||
return Box::pin(async move {
|
||||
let result = engine.execute_script(script, ctx, event_tx).await;
|
||||
match result {
|
||||
Ok(r) => Ok(r.stdout),
|
||||
Err(ExecutionError::ExecutionFailed { exit_code, .. })
|
||||
if exit_code == PIPELINE_SKIP_ERRORCODE as i32 =>
|
||||
{
|
||||
log::debug!("Function skipped (@if condition)");
|
||||
Ok(String::new())
|
||||
}
|
||||
Err(e) => Err(BakeError::ScriptExecutionError(e)),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
match combos[idx] {
|
||||
Decorator::Loop(count) => Box::pin(async move {
|
||||
let mut last_stdout = String::new();
|
||||
for i in 0..*count {
|
||||
let script_debug = DebugFeature::from_bits_retain(ctx.debug_flags).contains(DebugFeature::Script);
|
||||
if script_debug {
|
||||
log::info!("Loop {}/{}", i + 1, count);
|
||||
} else {
|
||||
log::debug!("Loop {}/{}", i + 1, count);
|
||||
}
|
||||
last_stdout = build_combinator_chain(
|
||||
engine, script, ctx, event_tx, combos, idx + 1,
|
||||
)
|
||||
.await?;
|
||||
// Feed stdout as stdin for the next iteration (@pipe(self))
|
||||
if i + 1 < *count {
|
||||
ctx.stdin = Some(last_stdout.clone().into_bytes());
|
||||
}
|
||||
}
|
||||
Ok(last_stdout)
|
||||
}),
|
||||
Decorator::Timeout(secs) => Box::pin(async move {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(*secs as u64),
|
||||
build_combinator_chain(
|
||||
engine, script, ctx, event_tx, combos, idx + 1,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| BakeError::ScriptExecutionError(ExecutionError::Timeout))?
|
||||
}),
|
||||
Decorator::Retry(retries, delay_secs) => Box::pin(async move {
|
||||
let max_attempts = *retries as usize + 1;
|
||||
let delay = Duration::from_secs(*delay_secs as u64);
|
||||
let mut last_err = None;
|
||||
for attempt in 0..max_attempts {
|
||||
match build_combinator_chain(
|
||||
engine, script, ctx, event_tx, combos, idx + 1,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(stdout) => return Ok(stdout),
|
||||
Err(e) => {
|
||||
if matches!(
|
||||
&e,
|
||||
BakeError::ScriptExecutionError(ExecutionError::Timeout)
|
||||
) {
|
||||
return Err(e);
|
||||
}
|
||||
if attempt < max_attempts - 1 {
|
||||
last_err = Some(e);
|
||||
log::warn!(
|
||||
"Retry {}/{} after {}s",
|
||||
attempt + 1,
|
||||
retries,
|
||||
delay_secs
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
} else {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap())
|
||||
}),
|
||||
_ => build_combinator_chain(
|
||||
engine, script, ctx, event_tx, combos, idx + 1,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire a batch of parallel nodes concurrently.
|
||||
///
|
||||
/// All nodes in the batch are waited on before returning. A non-`@fallible`
|
||||
/// failure is surfaced only after all nodes complete (GitHub/GitLab/Concourse
|
||||
/// convention).
|
||||
///
|
||||
/// Returns a `(name, stdout)` pair for each completed node so the caller can
|
||||
/// populate the stdout cache for `@pipe`.
|
||||
pub async fn fire_parallel_batch(
|
||||
batch: &[String],
|
||||
_engine: &Engine,
|
||||
func_map: &HashMap<String, Function>,
|
||||
ctx: &mut ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
workspace: &Path,
|
||||
remaining: &str,
|
||||
bake_base: &Path,
|
||||
stdout_cache: &HashMap<String, String>,
|
||||
) -> Result<Vec<(String, String)>, BakeError> {
|
||||
let handles: Vec<_> = batch
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let function = func_map[name.as_str()].clone();
|
||||
let mut task_ctx = ctx.clone();
|
||||
// Resolve per-task stdin from @pipe + stdout_cache
|
||||
task_ctx.stdin = resolve_pipe_stdin(&function, stdout_cache);
|
||||
let task_events = event_tx.clone();
|
||||
let task_workspace = workspace.to_path_buf();
|
||||
let task_remaining = remaining.to_string();
|
||||
let task_bake_base = bake_base.to_path_buf();
|
||||
let task_name = name.clone();
|
||||
tokio::spawn(async move {
|
||||
let task_engine = Engine::new();
|
||||
let result = execute_one(
|
||||
&task_engine,
|
||||
&function,
|
||||
&mut task_ctx,
|
||||
&task_events,
|
||||
&task_workspace,
|
||||
&task_remaining,
|
||||
&task_bake_base,
|
||||
)
|
||||
.await;
|
||||
(task_name, result)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut first_fatal: Option<BakeError> = None;
|
||||
let mut results: Vec<(String, String)> = Vec::with_capacity(handles.len());
|
||||
for handle in handles {
|
||||
let (name, result) = handle.await.map_err(|_| {
|
||||
BakeError::ScriptExecutionError(ExecutionError::ExecutionFailed {
|
||||
task_id: "parallel".to_string(),
|
||||
exit_code: -1,
|
||||
})
|
||||
})?;
|
||||
match result {
|
||||
Ok(stdout) => results.push((name, stdout)),
|
||||
Err(err) if is_fallible(&func_map[&name]) => {
|
||||
log::warn!("'{}' failed (fallible, non-fatal): {}", name, err);
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("'{}' failed (fatal): {}", name, err);
|
||||
first_fatal.get_or_insert(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(err) = first_fatal {
|
||||
return Err(err);
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Spawn an `@async` function in the background.
|
||||
/// Returns a `JoinHandle` that the caller must await before returning.
|
||||
pub fn execute_async(
|
||||
func: &Function,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
workspace: &Path,
|
||||
remaining: &str,
|
||||
bake_base: &Path,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
let mut task_ctx = ctx.clone();
|
||||
let task_events = event_tx.clone();
|
||||
let task_workspace = workspace.to_path_buf();
|
||||
let task_remaining = remaining.to_string();
|
||||
let task_bake_base = bake_base.to_path_buf();
|
||||
let task_func = func.clone();
|
||||
tokio::spawn(async move {
|
||||
let task_engine = Engine::new();
|
||||
let _ = execute_one(
|
||||
&task_engine, &task_func, &mut task_ctx,
|
||||
&task_events, &task_workspace,
|
||||
&task_remaining, &task_bake_base,
|
||||
).await;
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a Normal-mode function, resolving pipe stdin first.
|
||||
pub async fn execute_normal(
|
||||
engine: &Engine,
|
||||
func: &Function,
|
||||
ctx: &mut ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
workspace: &Path,
|
||||
remaining: &str,
|
||||
bake_base: &Path,
|
||||
stdout_cache: &HashMap<String, String>,
|
||||
) -> Result<String, BakeError> {
|
||||
ctx.stdin = resolve_pipe_stdin(func, stdout_cache);
|
||||
execute_one(engine, func, ctx, event_tx, workspace, remaining, bake_base).await
|
||||
}
|
||||
|
||||
// ── Daemon / Health ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Execute a `@daemon` function: build script, spawn, periodic health-probe
|
||||
/// (blocking until healthy), then return.
|
||||
pub async fn execute_daemon(
|
||||
engine: &Engine,
|
||||
func: &Function,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
workspace: &Path,
|
||||
remaining: &str,
|
||||
bake_base: &Path,
|
||||
health_period: Duration,
|
||||
health_timeout: Duration,
|
||||
health_max_retries: u32,
|
||||
) -> Result<(), BakeError> {
|
||||
let fallible = is_fallible(func);
|
||||
|
||||
let mut modified = func.clone();
|
||||
modified.body = remaining.to_string() + &func.body;
|
||||
let script = builder::build_script(
|
||||
&modified, bake_base, workspace, RenderMode::Full, ctx.debug_flags,
|
||||
)?;
|
||||
|
||||
// Spawn without waiting
|
||||
let mut command = tokio::process::Command::new(ctx.shell.as_str());
|
||||
command.arg("-c");
|
||||
command.arg(script.to_str().unwrap());
|
||||
command.current_dir(&ctx.working_dir);
|
||||
command.envs(&ctx.env_vars);
|
||||
command.stdin(Stdio::null());
|
||||
command.stdout(Stdio::null());
|
||||
command.stderr(Stdio::null());
|
||||
#[cfg(unix)]
|
||||
command.process_group(0);
|
||||
|
||||
//let child = // Necessary?
|
||||
match command.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) if fallible => {
|
||||
log::warn!("Failed to spawn daemon '{}' (fallible): {}", func.name, e);
|
||||
return Ok(());
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(BakeError::ScriptExecutionError(ExecutionError::ExecutionFailed {
|
||||
task_id: func.name.clone(),
|
||||
exit_code: -1,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Periodic health probe — block until healthy, retry exhausted, or fatal
|
||||
if let Some(health_cmd) = func.decorators.iter().find_map(|d| match d {
|
||||
Decorator::Health(cmd) => Some(cmd.as_str()),
|
||||
_ => None,
|
||||
}) {
|
||||
let mut retries: u32 = 0;
|
||||
loop {
|
||||
let probe_result = tokio::time::timeout(
|
||||
health_timeout,
|
||||
health_probe_one(engine, health_cmd, remaining, ctx, event_tx),
|
||||
)
|
||||
.await;
|
||||
|
||||
match probe_result {
|
||||
Ok(Ok(())) => break, // healthy — done
|
||||
_ if retries + 1 < health_max_retries => {
|
||||
retries += 1;
|
||||
log::warn!(
|
||||
"Health probe for '{}' failed ({}/{}), retrying in {:?}...",
|
||||
func.name, retries, health_max_retries, health_period,
|
||||
);
|
||||
}
|
||||
_ if fallible => {
|
||||
log::warn!("Health probe for '{}' exhausted (fallible), skipping", func.name);
|
||||
break; // exhausted but fallible → skip health check
|
||||
}
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => {
|
||||
return Err(BakeError::HealthProbeFailed {
|
||||
command: health_cmd.to_string(),
|
||||
error: "timeout".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(health_period).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup is handled by finalize.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Single health-probe attempt. Exit 0 = healthy.
|
||||
async fn health_probe_one(
|
||||
engine: &Engine,
|
||||
cmd: &str,
|
||||
remaining: &str,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
) -> Result<(), BakeError> {
|
||||
let full_cmd = format!("{}\n{}", remaining, cmd);
|
||||
let result = engine
|
||||
.execute_command(&full_cmd, ctx, event_tx)
|
||||
.await
|
||||
.map_err(|e| BakeError::HealthProbeFailed {
|
||||
command: cmd.to_string(),
|
||||
error: format!("{}", e),
|
||||
})?;
|
||||
if !result.success {
|
||||
return Err(BakeError::HealthProbeFailed {
|
||||
command: cmd.to_string(),
|
||||
error: format!("exit code {}", result.exit_code),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::decorator::{Decorator, parse_decorator};
|
||||
use crate::bake::BakeError;
|
||||
use crate::bake::error::BakeError;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use shlex::Shlex;
|
||||
@@ -12,14 +12,19 @@ lazy_static! {
|
||||
static ref DECORATOR_HEADER_REGEX: Regex = Regex::new(r"\s*#\s+@").unwrap();
|
||||
}
|
||||
|
||||
/// A parsed shell function with decorator annotations and body content.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Function {
|
||||
/// Function name (alphanumeric with underscore/hyphen).
|
||||
pub name: String,
|
||||
/// Decorators applied to this function.
|
||||
pub decorators: Vec<Decorator>,
|
||||
/// Raw function body including `function name() { ... }` syntax.
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl Function {
|
||||
/// Finds the first decorator matching the given predicate.
|
||||
pub fn find_decorator<T>(&self, matcher: impl Fn(&Decorator) -> Option<T>) -> Option<T> {
|
||||
self.decorators.iter().find_map(matcher)
|
||||
}
|
||||
@@ -37,12 +42,17 @@ enum ParserState {
|
||||
},
|
||||
}
|
||||
|
||||
/// Result of parsing a shell script with decorator annotations.
|
||||
#[derive(Debug)]
|
||||
pub struct ParsedScript {
|
||||
/// Functions marked with `@pipeline` decorator.
|
||||
pub pipeline_functions: Vec<Function>,
|
||||
/// Code not belonging to any pipeline function (comments, helpers, etc.).
|
||||
pub remaining_code: String,
|
||||
}
|
||||
|
||||
/// Parses shell scripts with `# @decorator(args)` annotations into a `Function` list.
|
||||
/// Lines prefixed with `# @` before a function definition become decorators for that function.
|
||||
pub fn parse_script(text: &str) -> Result<ParsedScript, BakeError> {
|
||||
let mut pipeline_functions = Vec::new();
|
||||
let mut remaining_code = String::new();
|
||||
@@ -164,7 +174,7 @@ pub fn parse_script(text: &str) -> Result<ParsedScript, BakeError> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
eprintln!(
|
||||
log::warn!(
|
||||
"Warning: Dangling decorator(s) at line {}. Treating as comments.",
|
||||
line_number - 1
|
||||
);
|
||||
@@ -214,6 +224,7 @@ pub fn parse_script(text: &str) -> Result<ParsedScript, BakeError> {
|
||||
{
|
||||
return Err(BakeError::ScriptGenerationFailed {
|
||||
script: "bake.sh".to_string(),
|
||||
action: "parsing function body".to_string(),
|
||||
reason: format!(
|
||||
"Unexpected EOF while parsing function '{}'. Unmatched braces (remaining: {})?",
|
||||
name, brace_depth
|
||||
@@ -224,6 +235,7 @@ pub fn parse_script(text: &str) -> Result<ParsedScript, BakeError> {
|
||||
let last_decorator = pending_decorators.last().unwrap();
|
||||
return Err(BakeError::ScriptGenerationFailed {
|
||||
script: "bake.sh".to_string(),
|
||||
action: "parsing decorators".to_string(),
|
||||
reason: format!(
|
||||
"Dangling decorator {} before EOF. Missing function definition?",
|
||||
last_decorator
|
||||
|
||||
+144
-469
@@ -1,39 +1,124 @@
|
||||
use super::{decorator::Decorator, parser::Function};
|
||||
use crate::error::BakeError;
|
||||
use std::collections::HashMap;
|
||||
use topological_sort::TopologicalSort;
|
||||
// Code in this module PARTIALLY or FULLY utilized AI Coding Agent
|
||||
|
||||
pub fn sort_function(functions: Vec<Function>) -> Result<Vec<Function>, BakeError> {
|
||||
let function_mapped = functions
|
||||
use super::decorator::Decorator;
|
||||
use super::parser::Function;
|
||||
use crate::bake::error::BakeError;
|
||||
use std::collections::HashMap;
|
||||
use workshop_schedule::{Dag, DagNode, EdgeKind, NodeColor};
|
||||
|
||||
/// Thin wrapper so `Function` implements `DagNode`.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct BakeNode {
|
||||
name: String,
|
||||
color: NodeColor,
|
||||
fallible: bool,
|
||||
/// Original function body + decorators for execution
|
||||
func: Function,
|
||||
}
|
||||
|
||||
impl DagNode for BakeNode {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
fn color(&self) -> NodeColor {
|
||||
self.color.clone()
|
||||
}
|
||||
fn fallible(&self) -> bool {
|
||||
self.fallible
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `Function` into a `BakeNode` by extracting scheduling metadata
|
||||
/// from decorators.
|
||||
fn function_to_node(func: &Function) -> BakeNode {
|
||||
let color = if func
|
||||
.decorators
|
||||
.iter()
|
||||
.any(|d| matches!(d, Decorator::Parallel(_)))
|
||||
{
|
||||
let group = func
|
||||
.decorators
|
||||
.iter()
|
||||
.find_map(|d| {
|
||||
if let Decorator::Parallel(Some(g)) = d {
|
||||
Some(g.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or("*".to_string());
|
||||
if group == "*" {
|
||||
NodeColor::Wildcard
|
||||
} else {
|
||||
NodeColor::Named(group)
|
||||
}
|
||||
} else {
|
||||
NodeColor::Sequential
|
||||
};
|
||||
|
||||
let fallible = func
|
||||
.decorators
|
||||
.iter()
|
||||
.any(|d| matches!(d, Decorator::Fallible));
|
||||
|
||||
BakeNode {
|
||||
name: func.name.clone(),
|
||||
color,
|
||||
fallible,
|
||||
func: func.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a DAG from functions using `@after` dependencies and implicit
|
||||
/// ordering for consecutive `@pipeline` functions.
|
||||
pub fn build_dag(
|
||||
functions: Vec<Function>,
|
||||
) -> Result<(Dag<BakeNode>, HashMap<String, Function>), BakeError> {
|
||||
let function_mapped: HashMap<&str, &Function> = functions
|
||||
.iter()
|
||||
.map(|f| (f.name.as_str(), f))
|
||||
.collect::<HashMap<&str, &Function>>();
|
||||
.collect();
|
||||
|
||||
let mut ts = TopologicalSort::<String>::new();
|
||||
|
||||
// Track last pipeline function for implicit @after
|
||||
let mut dag = Dag::new();
|
||||
let mut last_pipeline: Option<String> = None;
|
||||
|
||||
for function in &functions {
|
||||
ts.insert(function.name.clone());
|
||||
|
||||
// Determine whether this function is a pipeline node
|
||||
let is_pipeline = function
|
||||
.decorators
|
||||
.iter()
|
||||
.any(|d| matches!(d, Decorator::Pipeline));
|
||||
|
||||
if !is_pipeline {
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_after = function
|
||||
.decorators
|
||||
.iter()
|
||||
.any(|d| matches!(d, Decorator::After(_)));
|
||||
|
||||
// Add implicit @after for pipeline functions that don't have explicit @after
|
||||
if is_pipeline {
|
||||
if !has_after && let Some(ref last) = last_pipeline {
|
||||
ts.add_dependency(last, function.name.clone());
|
||||
let node = function_to_node(function);
|
||||
dag.add_node(node).map_err(|e| {
|
||||
BakeError::IncompatibleDecorators {
|
||||
function: function.name.clone(),
|
||||
reason: e.to_string(),
|
||||
}
|
||||
})?;
|
||||
|
||||
// Add implicit @after for consecutive @pipeline functions
|
||||
if !has_after {
|
||||
if let Some(ref last) = last_pipeline {
|
||||
dag.add_edge(last, &function.name, EdgeKind::Implicit)
|
||||
.map_err(|e| BakeError::IncompatibleDecorators {
|
||||
function: function.name.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
}
|
||||
}
|
||||
last_pipeline = Some(function.name.clone());
|
||||
}
|
||||
|
||||
// Add explicit @after edges
|
||||
for decorator in &function.decorators {
|
||||
if let Decorator::After(dependency) = decorator {
|
||||
if !function_mapped.contains_key(dependency.as_str()) {
|
||||
@@ -47,466 +132,56 @@ pub fn sort_function(functions: Vec<Function>) -> Result<Vec<Function>, BakeErro
|
||||
dependency.clone(),
|
||||
));
|
||||
}
|
||||
ts.add_dependency(dependency, function.name.clone());
|
||||
dag.add_edge(dependency, &function.name, EdgeKind::Explicit)
|
||||
.map_err(|e| BakeError::IncompatibleDecorators {
|
||||
function: function.name.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut functions_sorted = Vec::<Function>::new();
|
||||
while let Some(name) = ts.pop() {
|
||||
let function = function_mapped[name.as_str()];
|
||||
functions_sorted.push(function.clone());
|
||||
}
|
||||
dag.freeze();
|
||||
|
||||
if !ts.is_empty() {
|
||||
let remaining: Vec<String> = ts.collect();
|
||||
return Err(BakeError::CircularDependency(remaining));
|
||||
}
|
||||
let owned_map: HashMap<String, Function> = functions
|
||||
.into_iter()
|
||||
.map(|f| (f.name.clone(), f))
|
||||
.collect();
|
||||
|
||||
Ok(functions_sorted)
|
||||
Ok((dag, owned_map))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_function(name: &str, decorators: Vec<Decorator>, body: &str) -> Function {
|
||||
Function {
|
||||
name: name.to_string(),
|
||||
decorators,
|
||||
body: body.to_string(),
|
||||
/// Group ready parallel nodes by their declared group.
|
||||
/// Returns `(star_nodes, named_groups)`.
|
||||
///
|
||||
/// Deprecated: use `Dag::ready_wildcards()` and `Dag::ready_groups()` instead.
|
||||
pub fn partition_by_group<'a>(
|
||||
ready: &[String],
|
||||
dag: &Dag<BakeNode>,
|
||||
) -> (Vec<String>, HashMap<String, Vec<String>>) {
|
||||
let mut star_nodes = Vec::new();
|
||||
let mut named_groups: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for name in ready {
|
||||
if dag.is_wildcard(name) {
|
||||
star_nodes.push(name.clone());
|
||||
} else if let Some(g) = dag.group_of(name) {
|
||||
named_groups.entry(g).or_default().push(name.clone());
|
||||
}
|
||||
// Sequential nodes: not included (caller handles them)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_functions() {
|
||||
let functions = vec![];
|
||||
let result = sort_function(functions).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_function_no_dependencies() {
|
||||
let function = create_function("func1", vec![], "body1");
|
||||
let functions = vec![function.clone()];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].name, "func1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_functions_no_dependencies() {
|
||||
let function1 = create_function("func1", vec![], "body1");
|
||||
let function2 = create_function("func2", vec![], "body2");
|
||||
let function3 = create_function("func3", vec![], "body3");
|
||||
let functions = vec![function1.clone(), function2.clone(), function3.clone()];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
|
||||
assert_eq!(result.len(), 3);
|
||||
let names: Vec<String> = result.iter().map(|f| f.name.clone()).collect();
|
||||
assert!(names.contains(&"func1".to_string()));
|
||||
assert!(names.contains(&"func2".to_string()));
|
||||
assert!(names.contains(&"func3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_dependency() {
|
||||
let function1 = create_function("func1", vec![], "body1");
|
||||
let function2 = create_function(
|
||||
"func2",
|
||||
vec![Decorator::After("func1".to_string())],
|
||||
"body2",
|
||||
);
|
||||
let functions = vec![function1.clone(), function2.clone()];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].name, "func1");
|
||||
assert_eq!(result[1].name, "func2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_dependencies() {
|
||||
let function1 = create_function("func1", vec![], "body1");
|
||||
let function2 = create_function(
|
||||
"func2",
|
||||
vec![Decorator::After("func1".to_string())],
|
||||
"body2",
|
||||
);
|
||||
let function3 = create_function(
|
||||
"func3",
|
||||
vec![Decorator::After("func2".to_string())],
|
||||
"body3",
|
||||
);
|
||||
let functions = vec![function1.clone(), function2.clone(), function3.clone()];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0].name, "func1");
|
||||
assert_eq!(result[1].name, "func2");
|
||||
assert_eq!(result[2].name, "func3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_dependencies() {
|
||||
let function1 = create_function("func1", vec![], "body1");
|
||||
let function2 = create_function(
|
||||
"func2",
|
||||
vec![
|
||||
Decorator::After("func1".to_string()),
|
||||
Decorator::After("func3".to_string()),
|
||||
],
|
||||
"body2",
|
||||
);
|
||||
let function3 = create_function(
|
||||
"func3",
|
||||
vec![Decorator::After("func1".to_string())],
|
||||
"body3",
|
||||
);
|
||||
let function4 = create_function("func4", vec![], "body4");
|
||||
let functions = vec![
|
||||
function1.clone(),
|
||||
function2.clone(),
|
||||
function3.clone(),
|
||||
function4.clone(),
|
||||
];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
|
||||
assert_eq!(result.len(), 4);
|
||||
|
||||
let positions: std::collections::HashMap<String, usize> = result
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| (f.name.clone(), i))
|
||||
.collect();
|
||||
|
||||
assert!(positions.get("func1").unwrap() < positions.get("func2").unwrap());
|
||||
assert!(positions.get("func1").unwrap() < positions.get("func3").unwrap());
|
||||
assert!(positions.get("func3").unwrap() < positions.get("func2").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_decorators() {
|
||||
let function1 = create_function(
|
||||
"func1",
|
||||
vec![Decorator::Pipeline, Decorator::Timeout(1000)],
|
||||
"body1",
|
||||
);
|
||||
let function2 = create_function(
|
||||
"func2",
|
||||
vec![
|
||||
Decorator::After("func1".to_string()),
|
||||
Decorator::Retry(3, 100),
|
||||
Decorator::Fallible,
|
||||
],
|
||||
"body2",
|
||||
);
|
||||
let functions = vec![function1.clone(), function2.clone()];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].name, "func1");
|
||||
assert_eq!(result[1].name, "func2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_function_with_multiple_after_decorators() {
|
||||
let function1 = create_function("func1", vec![], "body1");
|
||||
let function2 = create_function("func2", vec![], "body2");
|
||||
let function3 = create_function(
|
||||
"func3",
|
||||
vec![
|
||||
Decorator::After("func1".to_string()),
|
||||
Decorator::After("func2".to_string()),
|
||||
],
|
||||
"body3",
|
||||
);
|
||||
let functions = vec![function1.clone(), function2.clone(), function3.clone()];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
|
||||
assert_eq!(result.len(), 3);
|
||||
|
||||
let positions: std::collections::HashMap<String, usize> = result
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| (f.name.clone(), i))
|
||||
.collect();
|
||||
|
||||
assert!(positions.get("func1").unwrap() < positions.get("func3").unwrap());
|
||||
assert!(positions.get("func2").unwrap() < positions.get("func3").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignores_non_after_decorators() {
|
||||
let function1 = create_function(
|
||||
"func1",
|
||||
vec![
|
||||
Decorator::Pipeline,
|
||||
Decorator::If("condition".to_string()),
|
||||
Decorator::Fallible,
|
||||
],
|
||||
"body1",
|
||||
);
|
||||
let function2 = create_function(
|
||||
"func2",
|
||||
vec![
|
||||
Decorator::Loop(5),
|
||||
Decorator::Timeout(1000),
|
||||
Decorator::Retry(3, 100),
|
||||
Decorator::Export,
|
||||
],
|
||||
"body2",
|
||||
);
|
||||
let functions = vec![function1.clone(), function2.clone()];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
|
||||
assert_eq!(result.len(), 2);
|
||||
let names: Vec<String> = result.iter().map(|f| f.name.clone()).collect();
|
||||
assert!(names.contains(&"func1".to_string()));
|
||||
assert!(names.contains(&"func2".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_implicit_after_pipeline() {
|
||||
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
|
||||
let func2 = create_function("func2", vec![Decorator::Pipeline], "body2");
|
||||
let func3 = create_function("func3", vec![Decorator::Pipeline], "body3");
|
||||
let functions = vec![func1.clone(), func2.clone(), func3.clone()];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0].name, "func1");
|
||||
assert_eq!(result[1].name, "func2");
|
||||
assert_eq!(result[2].name, "func3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_pipeline_no_implicit_after() {
|
||||
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
|
||||
let functions = vec![func1.clone()];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].name, "func1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_explicit_after_overrides_implicit() {
|
||||
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
|
||||
let func2 = create_function(
|
||||
"func2",
|
||||
vec![Decorator::Pipeline, Decorator::After("func1".to_string())],
|
||||
"body2",
|
||||
);
|
||||
let func3 = create_function("func3", vec![Decorator::Pipeline], "body3");
|
||||
let functions = vec![func1.clone(), func2.clone(), func3.clone()];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
|
||||
assert_eq!(result.len(), 3);
|
||||
let positions: std::collections::HashMap<String, usize> = result
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| (f.name.clone(), i))
|
||||
.collect();
|
||||
|
||||
assert!(positions.get("func1").unwrap() < positions.get("func2").unwrap());
|
||||
assert!(positions.get("func2").unwrap() < positions.get("func3").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_pipeline_and_non_pipeline() {
|
||||
let func1 = create_function("func1", vec![], "body1");
|
||||
let func2 = create_function("func2", vec![Decorator::Pipeline], "body2");
|
||||
let func3 = create_function("func3", vec![], "body3");
|
||||
let func4 = create_function("func4", vec![Decorator::Pipeline], "body4");
|
||||
let functions = vec![func1.clone(), func2.clone(), func3.clone(), func4.clone()];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
|
||||
assert_eq!(result.len(), 4);
|
||||
let positions: std::collections::HashMap<String, usize> = result
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| (f.name.clone(), i))
|
||||
.collect();
|
||||
|
||||
assert!(positions.get("func2").unwrap() < positions.get("func4").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_pipeline_works() {
|
||||
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
|
||||
let functions = vec![func1.clone()];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].name, "func1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_two_pipelines_implicit_chain() {
|
||||
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
|
||||
let func2 = create_function("func2", vec![Decorator::Pipeline], "body2");
|
||||
let functions = vec![func1.clone(), func2.clone()];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
assert_eq!(result.len(), 2);
|
||||
let positions: std::collections::HashMap<String, usize> = result
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| (f.name.clone(), i))
|
||||
.collect();
|
||||
assert!(positions.get("func1").unwrap() < positions.get("func2").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pipeline_explicit_after_skips_implicit() {
|
||||
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
|
||||
let func2 = create_function("func2", vec![Decorator::Pipeline], "body2");
|
||||
let func3 = create_function(
|
||||
"func3",
|
||||
vec![Decorator::Pipeline, Decorator::After("func1".to_string())],
|
||||
"body3",
|
||||
);
|
||||
let functions = vec![func1.clone(), func2.clone(), func3.clone()];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
assert_eq!(result.len(), 3);
|
||||
let positions: std::collections::HashMap<String, usize> = result
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| (f.name.clone(), i))
|
||||
.collect();
|
||||
assert!(positions.get("func1").unwrap() < positions.get("func3").unwrap());
|
||||
// TODO: Our design spec indicates order-based sorting, however toposort crate does not guarantee order of independent nodes. We should consider whether we want to enforce order-based sorting for pipelines, or if we are okay with any valid topological order.
|
||||
// assert!(positions.get("func2").unwrap() < positions.get("func3").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circular_dependency_detected() {
|
||||
let func1 = create_function(
|
||||
"func1",
|
||||
vec![Decorator::After("func2".to_string())],
|
||||
"body1",
|
||||
);
|
||||
let func2 = create_function(
|
||||
"func2",
|
||||
vec![Decorator::After("func1".to_string())],
|
||||
"body2",
|
||||
);
|
||||
let functions = vec![func1.clone(), func2.clone()];
|
||||
|
||||
let result = sort_function(functions);
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
BakeError::CircularDependency(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_dependency_error() {
|
||||
let func1 = create_function(
|
||||
"func1",
|
||||
vec![Decorator::After("nonexistent".to_string())],
|
||||
"body1",
|
||||
);
|
||||
let functions = vec![func1.clone()];
|
||||
|
||||
let result = sort_function(functions);
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
BakeError::UnknownDependency(_, _)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pipeline_with_multiple_decorators() {
|
||||
let func1 = create_function(
|
||||
"func1",
|
||||
vec![
|
||||
Decorator::Pipeline,
|
||||
Decorator::Timeout(60),
|
||||
Decorator::Fallible,
|
||||
],
|
||||
"body1",
|
||||
);
|
||||
let func2 = create_function(
|
||||
"func2",
|
||||
vec![
|
||||
Decorator::Pipeline,
|
||||
Decorator::Retry(3, 5),
|
||||
Decorator::After("func1".to_string()),
|
||||
],
|
||||
"body2",
|
||||
);
|
||||
let functions = vec![func1.clone(), func2.clone()];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
assert_eq!(result.len(), 2);
|
||||
let positions: std::collections::HashMap<String, usize> = result
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| (f.name.clone(), i))
|
||||
.collect();
|
||||
assert!(positions.get("func1").unwrap() < positions.get("func2").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_long_pipeline_chain() {
|
||||
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
|
||||
let func2 = create_function("func2", vec![Decorator::Pipeline], "body2");
|
||||
let func3 = create_function("func3", vec![Decorator::Pipeline], "body3");
|
||||
let func4 = create_function("func4", vec![Decorator::Pipeline], "body4");
|
||||
let func5 = create_function("func5", vec![Decorator::Pipeline], "body5");
|
||||
let functions = vec![
|
||||
func1.clone(),
|
||||
func2.clone(),
|
||||
func3.clone(),
|
||||
func4.clone(),
|
||||
func5.clone(),
|
||||
];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
assert_eq!(result.len(), 5);
|
||||
let positions: std::collections::HashMap<String, usize> = result
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| (f.name.clone(), i))
|
||||
.collect();
|
||||
assert!(positions.get("func1").unwrap() < positions.get("func2").unwrap());
|
||||
assert!(positions.get("func2").unwrap() < positions.get("func3").unwrap());
|
||||
assert!(positions.get("func3").unwrap() < positions.get("func4").unwrap());
|
||||
assert!(positions.get("func4").unwrap() < positions.get("func5").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_pipeline_between_pipelines() {
|
||||
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
|
||||
let func2 = create_function("func2", vec![], "body2");
|
||||
let func3 = create_function("func3", vec![Decorator::Pipeline], "body3");
|
||||
let functions = vec![func1.clone(), func2.clone(), func3.clone()];
|
||||
|
||||
let result = sort_function(functions).unwrap();
|
||||
assert_eq!(result.len(), 3);
|
||||
let positions: std::collections::HashMap<String, usize> = result
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| (f.name.clone(), i))
|
||||
.collect();
|
||||
assert!(positions.get("func1").unwrap() < positions.get("func3").unwrap());
|
||||
}
|
||||
(star_nodes, named_groups)
|
||||
}
|
||||
|
||||
/// Pick a complete group to execute.
|
||||
///
|
||||
/// Deprecated: use `Dag::pick_batch()` instead.
|
||||
pub fn pick_batch(
|
||||
_star_nodes: &[String],
|
||||
_groups: &HashMap<String, Vec<String>>,
|
||||
_group_totals: &HashMap<String, usize>,
|
||||
) -> Option<Vec<String>> {
|
||||
// This function is replaced by Dag::pick_batch() which has all the
|
||||
// group metadata internally. Keeping the signature for backward compat
|
||||
// but redirect callers to use Dag directly.
|
||||
unimplemented!("use dag.pick_batch() instead")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
use crate::bake::builder::{build_script, RenderMode};
|
||||
use crate::bake::error::BakeError;
|
||||
use crate::bake::parser::Function;
|
||||
use std::path::Path;
|
||||
use workshop_engine::{Engine, EventSender, ExecutionContext};
|
||||
|
||||
/// Executes `script_content` as a single function in trivial mode.
|
||||
///
|
||||
/// Two entry paths lead here:
|
||||
/// - **Auto-detection** (`!has_pipeline`): bake.sh has no `@pipeline` functions,
|
||||
/// the entire content is treated as one trivial function.
|
||||
/// - **Explicit PIP** (future): `trivial` parameter forces trivial mode even
|
||||
/// when `@pipeline` functions exist, typically with `RenderMode::Off`.
|
||||
pub async fn run_trivial(
|
||||
script_content: &str,
|
||||
bake_base_path: &Path,
|
||||
workspace: &Path,
|
||||
engine: &Engine,
|
||||
ctx: &mut ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
render_mode: RenderMode,
|
||||
) -> Result<(), BakeError> {
|
||||
let function = Function {
|
||||
name: String::new(),
|
||||
decorators: vec![],
|
||||
body: script_content.to_string(),
|
||||
};
|
||||
let script = build_script(&function, bake_base_path, workspace, render_mode, ctx.debug_flags)?;
|
||||
engine.execute_script(&script, ctx, event_tx).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use crate::bake::decorator::Decorator;
|
||||
use crate::bake::parser::Function;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Execution mode for a pipeline function.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FuncMode {
|
||||
Normal,
|
||||
Daemon,
|
||||
Async,
|
||||
}
|
||||
|
||||
/// Determine the execution mode from decorators.
|
||||
/// `@daemon`, `@async`, `@pipe` are mutually exclusive modes.
|
||||
pub fn resolve_mode(func: &Function) -> FuncMode {
|
||||
for d in &func.decorators {
|
||||
match d {
|
||||
Decorator::Daemon => return FuncMode::Daemon,
|
||||
Decorator::Async => return FuncMode::Async,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
FuncMode::Normal
|
||||
}
|
||||
|
||||
/// True if the function is decorated with `@parallel`.
|
||||
pub fn is_parallel(func: &Function) -> bool {
|
||||
func.decorators
|
||||
.iter()
|
||||
.any(|d| matches!(d, Decorator::Parallel(_)))
|
||||
}
|
||||
|
||||
/// True if the function is decorated with `@fallible`.
|
||||
pub fn is_fallible(func: &Function) -> bool {
|
||||
func.decorators
|
||||
.iter()
|
||||
.any(|d| matches!(d, Decorator::Fallible))
|
||||
}
|
||||
|
||||
/// Resolve stdin for a `@pipe`-decorated function from the stdout cache.
|
||||
///
|
||||
/// Rule 1: self-references are skipped (handled by loop combinator).
|
||||
/// Rule 3: sources present in the cache are concatenated.
|
||||
/// Returns `None` if any source is missing (caller decides based on `@fallible`).
|
||||
pub fn resolve_pipe_stdin(
|
||||
func: &Function,
|
||||
cache: &HashMap<String, String>,
|
||||
) -> Option<Vec<u8>> {
|
||||
let sources = func.decorators.iter().find_map(|d| match d {
|
||||
Decorator::Pipe(names) => Some(names.as_slice()),
|
||||
_ => None,
|
||||
})?;
|
||||
let mut combined = String::new();
|
||||
for source in sources {
|
||||
if *source == func.name {
|
||||
continue; // Rule 1: skip self
|
||||
}
|
||||
combined.push_str(cache.get(source)?); // Rule 3
|
||||
}
|
||||
Some(combined.into_bytes())
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use crate::cli::{BareCommands, Cli};
|
||||
use crate::error::CliError;
|
||||
use crate::types::resource::ResourceRegistry;
|
||||
use crate::{bake::bake, finalize::finalize, prebake::prebake};
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
|
||||
pub async fn run_bare(
|
||||
cli: &Cli,
|
||||
command: &BareCommands,
|
||||
ctx: &mut ExecutionContext,
|
||||
event_tx: EventSender,
|
||||
pipeline: &str,
|
||||
build_id: &str,
|
||||
registry: &ResourceRegistry,
|
||||
) -> Result<(), CliError> {
|
||||
let (stage, config) = match command {
|
||||
BareCommands::Prebake { .. } => ("prebake", &cli.prebake),
|
||||
BareCommands::Bake { .. } => ("bake", &cli.bake),
|
||||
BareCommands::Finalize { .. } => {
|
||||
("finalize", &cli.finalize)
|
||||
}
|
||||
};
|
||||
log::debug!("Running bare command {} with config {}", stage, config.display());
|
||||
|
||||
let prebake_path = &cli.prebake;
|
||||
let bake_base = &cli.bake_base;
|
||||
|
||||
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, None).await?;
|
||||
}
|
||||
BareCommands::Finalize { .. } => {
|
||||
finalize(&config, cli, ctx, event_tx, registry).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
pub const DEFAULT_WORKSPACE: &str = "/workspace";
|
||||
pub const BOOTSTRAP_SCRIPT_PATH: &str = "/bootstrap.sh";
|
||||
pub const BAKE_SCRIPT_PATH: &str = "bake.sh";
|
||||
pub const PIPELINE_SKIP_ERRORCODE: u8 = 233;
|
||||
pub const TRIVIAL_SCRIPT_NAME: &str = "BAKE_TRIVIAL";
|
||||
pub const FINALIZE_PLUGIN_MANIFEST: &str = "manifest";
|
||||
pub const FINALIZE_PLUGIN_MANIFEST_EXTENSION: &[&str] = &[".yml", ".yaml"];
|
||||
pub const FINALIZE_SHELL_ENVPREFIX: &str = "HBW_ARG";
|
||||
pub const FINALIZE_STANDALONE_PLUGIN_PATH: &str = "/tmp/baker/plugin";
|
||||
|
||||
/// Standalone mode build status file directory
|
||||
pub const STANDALONE_STATUS_DIR: &str = "/tmp";
|
||||
/// Standalone mode build status file name
|
||||
pub const STANDALONE_STATUS_FILE: &str = ".hbwstatus";
|
||||
@@ -1,193 +0,0 @@
|
||||
use crate::cli::Cli;
|
||||
// use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
// use std::path::PathBuf;
|
||||
// use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
// use tokio::process::{Child, Command};
|
||||
// use tokio::signal;
|
||||
// use tokio::sync::RwLock;
|
||||
// use tokio::time::{Duration, interval};
|
||||
|
||||
// #[derive(Debug, Clone, Serialize)]
|
||||
// struct Heartbeat {
|
||||
// pipeline_id: String,
|
||||
// build_id: String,
|
||||
// stage: String,
|
||||
// timestamp: u64,
|
||||
// status: String,
|
||||
// subtasks: Vec<String>,
|
||||
// }
|
||||
|
||||
pub async fn daemon(_cli: &Cli) {
|
||||
todo!("Daemon should be refactored");
|
||||
// let socket = PathBuf::from(settings.get_string("socket").unwrap());
|
||||
// let agentsocket = settings.get_string("agentsocket").unwrap();
|
||||
// let _ = std::fs::remove_file(&socket);
|
||||
|
||||
// let heartbeat = Arc::new(RwLock::new(Heartbeat {
|
||||
// pipeline_id: cli.pipeline.clone(),
|
||||
// build_id: cli.build_id.clone(),
|
||||
// stage: String::new(),
|
||||
// timestamp: 0,
|
||||
// status: String::new(),
|
||||
// subtasks: Vec::new(),
|
||||
// }));
|
||||
|
||||
// // let heartbeat_clone = heartbeat.clone();
|
||||
// let heartbeat_task = {
|
||||
// let hb = heartbeat.clone();
|
||||
// tokio::spawn(async move {
|
||||
// let mut tick = interval(Duration::from_secs(1));
|
||||
// loop {
|
||||
// tick.tick().await;
|
||||
// let mut hb = hb.write().await;
|
||||
// hb.timestamp = std::time::SystemTime::now()
|
||||
// .duration_since(std::time::UNIX_EPOCH)
|
||||
// .unwrap()
|
||||
// .as_millis() as u64;
|
||||
|
||||
// // 只输出到stdout(模拟未来发送给Agent)
|
||||
// println!("[HEARTBEAT] {}", serde_json::to_string(&*hb).unwrap());
|
||||
// }
|
||||
// })
|
||||
// };
|
||||
|
||||
// let socket_task = {
|
||||
// let socket = socket.clone();
|
||||
// tokio::spawn(async move {
|
||||
// log::info!("Creating Unix socket at {}", socket.display());
|
||||
// let listener =
|
||||
// UnixListener::bind(&socket).expect("Failed to bind Unix socket");
|
||||
|
||||
// loop {
|
||||
// match listener.accept().await {
|
||||
// Ok((stream, _)) => {
|
||||
// tokio::spawn(handle_connection(stream));
|
||||
// }
|
||||
// Err(e) => {
|
||||
// log::error!("Accept error: {}", e);
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// };
|
||||
|
||||
// let mut tasks: Vec<TaskSpec> = Vec::new();
|
||||
|
||||
// let execution_task = {
|
||||
// let hb = heartbeat.clone();
|
||||
// tokio::spawn(async move {
|
||||
// let mut hb = hb.write().await;
|
||||
// let mut children: Vec<(String, Child)> = vec![];
|
||||
|
||||
// let executable = std::env::current_exe().unwrap();
|
||||
|
||||
// for (index, task) in tasks.iter().enumerate() {
|
||||
// let mut command = task.command(&executable);
|
||||
// command.stdout(std::process::Stdio::piped());
|
||||
// command.stderr(std::process::Stdio::piped());
|
||||
// let child = command
|
||||
// .spawn()
|
||||
// .expect(format!("Failed to spawn child {}", index).as_str());
|
||||
|
||||
// let pid = child.id().unwrap_or(0).to_string();
|
||||
// children.push((pid.clone(), child));
|
||||
|
||||
// hb.subtasks.push(pid.clone());
|
||||
// }
|
||||
|
||||
// for (pid, mut child) in children {
|
||||
// let status = child.wait().await.expect("Failed to wait child");
|
||||
// log::info!("Subtask {} exited with: {}", pid, status);
|
||||
|
||||
// hb.subtasks.retain(|p| p != &pid);
|
||||
// }
|
||||
|
||||
// hb.stage = "Success".to_string();
|
||||
// })
|
||||
// };
|
||||
|
||||
// tokio::select! {
|
||||
// _ = heartbeat_task => {
|
||||
// log::error!("Heartbeat task died");
|
||||
// }
|
||||
// _ = execution_task => {
|
||||
// log::info!("All subtasks completed");
|
||||
// }
|
||||
// _ = socket_task => {
|
||||
// log::error!("Socket task died");
|
||||
// }
|
||||
// _ = signal::ctrl_c() => {
|
||||
// log::info!("Shutting down...");
|
||||
// }
|
||||
// }
|
||||
// for pid in heartbeat.read().await.subtasks.clone() {
|
||||
// let _ = Command::new("kill").arg("-TERM").arg(&pid).status().await;
|
||||
// }
|
||||
|
||||
// let _ = std::fs::remove_file(&socket);
|
||||
}
|
||||
|
||||
async fn _handle_connection(stream: UnixStream) {
|
||||
let peer_addr = match stream.peer_addr() {
|
||||
Ok(addr) => format!("{:?}", addr),
|
||||
Err(_) => "unknown".to_string(),
|
||||
};
|
||||
|
||||
log::debug!("New connection from: {}", peer_addr);
|
||||
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) => {
|
||||
log::debug!("Connection closed by peer: {}", peer_addr);
|
||||
break;
|
||||
}
|
||||
Ok(_) => {
|
||||
// 简单打印原始JSON(预留字段解析空间)
|
||||
match serde_json::from_str::<Value>(&line) {
|
||||
Ok(json) => {
|
||||
// 只提取关键字段打印(字段可能变化)
|
||||
let msgtype = json
|
||||
.get("msgtype")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let name = json
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unnamed");
|
||||
|
||||
log::info!("[{}] Message from '{}': {}", msgtype, name, line.trim());
|
||||
|
||||
// TODO: 字段稳定后,可扩展为结构化打印
|
||||
// match msgtype {
|
||||
// "ResourceUsage" => print_resource(&json),
|
||||
// "Log" => print_log(&json),
|
||||
// _ => log::debug!("Unknown type: {}", msgtype),
|
||||
// }
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Invalid JSON from {}: {} - Error: {}",
|
||||
peer_addr,
|
||||
line.trim(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to read from {}: {}", peer_addr, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!("Connection handler for {} exiting", peer_addr);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user