Compare commits
55 Commits
49aa1dc36c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| df5026cdbe | |||
| d1ad08ef3a | |||
| cf2968e720 | |||
| 28abc5d207 | |||
| 6e7b96cd66 | |||
| 3e80825d58 | |||
| c97eafab29 | |||
| f737b6a24d | |||
| 681b7d1333 | |||
| f07588a716 | |||
| b0530fb858 | |||
| fd18b547e5 | |||
| cf317b55c6 | |||
| 20e2ab4224 | |||
| 50b42e654c | |||
| a64e47d772 | |||
| 8fd807f174 | |||
| e8ce2eec9e | |||
| 9e45bdbbfb | |||
| 5ce8b72900 | |||
| 2c9d32e954 | |||
| 901005639b | |||
| 84b3b7d8e8 | |||
| 36112c671d | |||
| 9d3d61ba66 | |||
| 117660d5c4 | |||
| 07d76d612d | |||
| 2658d0ebfa | |||
| 40f307b471 | |||
| 7b3b71a4b3 | |||
| 0dd4a157ef | |||
| d8ba166a3b | |||
| e9128adfa0 | |||
| 0c089fd634 | |||
| a1da093176 | |||
| a8ed935f6a | |||
| 73e1238e20 | |||
| 246c2b22f1 | |||
| 418bdfb2f0 | |||
| 2133f8b9b3 | |||
| b59a702925 | |||
| b02407a02d | |||
| 15aaa6f310 | |||
| 9174c4dd2a | |||
| 20919d53ad | |||
| 3e8b256889 | |||
| 0f6aed6f94 | |||
| 960f03269c | |||
| 69056a3a4a | |||
| c80500f6ac | |||
| d7ba65fbf4 | |||
| ab7cbfd730 | |||
| ecf3c66905 | |||
| f7ea4dfb3e | |||
| f66a36d0f4 |
@@ -0,0 +1,62 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
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:
|
||||
ci:
|
||||
name: ${{ matrix.crate }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
crate: [workshop-baker, workshop-engine]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./${{ matrix.crate }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Cache cargo dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
${{ matrix.crate }}/target
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.crate }}-${{ hashFiles(format('{0}/Cargo.lock', matrix.crate)) }}
|
||||
restore-keys: |
|
||||
${{ 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: Run Clippy lints
|
||||
run: cargo clippy --all-features -- -D warnings
|
||||
+20
@@ -1,2 +1,22 @@
|
||||
target
|
||||
playground
|
||||
*.old
|
||||
.ruff_cache
|
||||
|
||||
.codeartsdoer
|
||||
archived
|
||||
.sisyphus
|
||||
*.bak
|
||||
*.clean
|
||||
session*
|
||||
.secret
|
||||
|
||||
# System test artifacts
|
||||
tests/results/
|
||||
tests/__pycache__/
|
||||
tests/**/__pycache__/
|
||||
tests/.pytest_cache/
|
||||
tests/llm/.cache/
|
||||
*.pyc
|
||||
.staging
|
||||
.pytest_cache
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2026-05-19
|
||||
**Commit:** 28abc5d
|
||||
**Branch:** master
|
||||
|
||||
## OVERVIEW
|
||||
HoneyBiscuitWorkshop ("蜜饼工坊") — Rust CI/CD system with decorator-driven shell pipeline DSL and LLM-powered configuration. 3 independent Cargo crates, no workspace.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
./
|
||||
├── workshop-baker/ # Main orchestrator (CLI + pipeline stages)
|
||||
├── workshop-engine/ # Execution engine (extracted from baker)
|
||||
├── workshop-getterurl/ # URL fetching (go-getter style)
|
||||
├── docs/ # mdBook documentation (zh-CN)
|
||||
├── templates/ # Pipeline templates (bake.sh, finalize.yml, prebake.yml)
|
||||
└── archived/ # Deprecated crates (7 moved here)
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Baker (CLI) | `workshop-baker/src/{lib,main}.rs` | Clap CLI + tokio entry |
|
||||
| Pipeline stages | `workshop-baker/src/{bake,prebake,finalize}/` | Stage orchestration |
|
||||
| Notifications | `workshop-baker/src/notify/` | Plugin-based event notifications |
|
||||
|| Engine (execution) | `workshop-engine/src/` | Process mgmt, PM detection |
|
||||
| URL fetching | `workshop-getterurl/src/` | Git/HTTP/File resource getter |
|
||||
|
||||
## CODE MAP
|
||||
| Symbol | Type | Location | Role |
|
||||
|--------|------|----------|------|
|
||||
| Cli | struct | workshop-baker/src/lib.rs:21 | CLI argument struct |
|
||||
| ExecutionContext | struct | workshop-engine/src/types.rs:10 | Pipeline context |
|
||||
| Engine | struct | workshop-engine/src/types.rs:99 | Build executor (placeholder) |
|
||||
| PrebakeConfig | struct | workshop-baker/src/prebake/config.rs | Prebake YAML schema |
|
||||
| FinalizeConfig | struct | workshop-baker/src/finalize/config.rs:21 | Finalize YAML schema |
|
||||
| GetterUrl | struct | workshop-getterurl/src/lib.rs:84 | Parsed resource URL |
|
||||
| Getter | trait | workshop-getterurl/src/getter.rs:18 | Resource fetch trait |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
### Rust
|
||||
- **Edition**: 2024 (requires Rust 1.85+), deprecates `mod.rs`
|
||||
- **Naming**: `snake_case` files/modules, `PascalCase` types, `SCREAMING_SNAKE_CASE` consts
|
||||
- **Error handling**: `thiserror` + `anyhow` combo; `HasExitCode` trait for exit codes
|
||||
- **Async**: `#[tokio::main]` + `tokio` with "full" features
|
||||
- **Imports**: `std` → `external_crate` → `crate::module`
|
||||
- **Dual-mode**: CLI commands (prebake/bake/finalize) + daemon mode (unimplemented)
|
||||
|
||||
### Testing
|
||||
- **Rust**: `#[test]` inline, `#[tokio::test]` async, `tests/` integration
|
||||
- **Benchmarks**: Criterion (configured in Cargo.toml)
|
||||
|
||||
### CI
|
||||
- **GitHub Actions**: rust-toolchain (stable), clippy + rustfmt, `cargo test --all-features`
|
||||
- **Matrix**: workshop-baker, workshop-engine
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
1. **Hardcoded user** — "vulcan" username hardcoded throughout prebake
|
||||
2. **Unsafe libc** — workshop-baker/src/prebake/security.rs has 5+ unsafe blocks
|
||||
3. **Rust 2024 edition** — May not work with stable toolchains
|
||||
4. **Empty daemon** — `daemon/` dir is empty, Daemon command is `unimplemented!()`
|
||||
|
||||
## UNIQUE STYLES
|
||||
- **Pipeline DSL**: Shell scripts with `# @decorator` annotations
|
||||
- **Notification system**: Plugin-based with batching, retry, priority groups
|
||||
- **LLM cookbook system**: YAML cookbooks for language-specific builds (archived)
|
||||
- **Chinese docs** — Documentation primarily in zh-CN
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
# Build
|
||||
cargo build --release -p workshop-baker
|
||||
|
||||
# Test
|
||||
cargo test -p workshop-baker
|
||||
cargo test -p workshop-engine
|
||||
|
||||
# Lint
|
||||
cargo clippy -- -D warnings
|
||||
cargo fmt --all
|
||||
|
||||
# Run
|
||||
cargo run --bin workshop-baker -- --pipeline demo --build-id 1 --prebake prebake.yml --bake bake.sh --finalize finalize.yml
|
||||
```
|
||||
|
||||
## NOTES
|
||||
- **Docker required** for integration tests
|
||||
- **No root workspace** — each crate independent, build separately
|
||||
- **7 archived crates** — workshop-agent, workshop-cert, workshop-deviceid, workshop-helper-mac, workshop-llm-detector, workshop-pipeline, workshop-vault
|
||||
- **Python tests removed** — pytest.ini exists but test files no longer in workspace
|
||||
-1
Submodule RustyVault deleted from 4efe033ce7
@@ -0,0 +1,62 @@
|
||||
# HBW(HoneyBiscuitWorkshop) 项目规划与介绍
|
||||
|
||||
本项目为一个基于Rust的CI/CD系统,名为“蜜饼工坊”(HoneyBiscuitWorkshop)。
|
||||
该名称取材于明日方舟世界观中小刻爱吃的蜜饼和她的管理者火神大姐的工坊。
|
||||
|
||||
## 目标愿景
|
||||
|
||||
这个CI/CD的最终用途如下:
|
||||
0. 首先是一个合格的CI/CD。其参考了concourse(concourse也是本项目的起始点与初衷,见Vol3/Story.md),意图实现其大部分功能。
|
||||
1. 软件包自动化
|
||||
接入AOSC的autobuild+abbs树,或者Arch Linux的ABS,实现软件包的自动构建与更新
|
||||
在Web/CLI(./workshop)中允许使用流水线模板快捷推送任务。示例:
|
||||
./workshop push aosc:linux-kernel-6.19
|
||||
./workshop push arch:python-pytorch-rocm
|
||||
2. 机器学习支持,支持CUDA/ROCm/Others方案运行机器学习/深度学习/大模型训练程序
|
||||
3. 对于部分工业软件/行业软件的支持,例如允许通过Vivado实现硬件设计远程综合,或者通过Blender渲染一个项目
|
||||
一定程度是2的延伸
|
||||
|
||||
## 特性设计
|
||||
|
||||
1. LLM Skill/MCP 适配
|
||||
允许通过Skill调用API/CLI(prefer!)与服务器通信,实现流水线自愈,流水线配置快速生成等
|
||||
2. 流水线进度感知
|
||||
具体说来程序可以通过读取的流水线日志来判断进行的进度,以估计剩余时间,或给出超时警告
|
||||
实现上尽可能避开大模型,使用纯粹机器学习/深度学习方法,TTR=1s,单流水线持久化数据不大于16KB
|
||||
3. 与koishi(Koishi.js,一个聊天机器人管理项目,https://koishi.chat/zh-CN/)等结合,实现消息推送与流水线简单管理(如快捷重启,定时启动等)
|
||||
4. 基于cgroups/JobObject(计划中)的任务资源管理与计数
|
||||
资源管理可以对任务资源进行限制
|
||||
资源计数可以对不同权限的账号计算配额
|
||||
5. prebake.yml, bake.sh, finalize.yml的最简流水线配置
|
||||
prebake.yml的模板于本目录给出,尽可能完善
|
||||
prebake为纯粹声明式配置,具有固定的用户自定义锚点,具有权限配置
|
||||
bake.sh的模板于本目录给出,仅供参考
|
||||
bake使用@decorator(私有)装饰器语法增强语义,主要是命令式,兼容纯shell
|
||||
finalize.yml的模板正在设计,计划上支持shell/rhai/dylib(待Rust ABI稳定)
|
||||
6. 静态二进制发布,支持各种架构,系统与C库
|
||||
我们尽可能为Rust Tier1架构做完整支持(Windows暂缓)
|
||||
会尤其注重新兴架构: RISC-V和LoongArch的支持
|
||||
理论上不支持32位,但不会限制
|
||||
!!!不支持没有操作系统的裸机(nostd)!!!
|
||||
7. 构建机支持裸机,容器,FireCracker虚拟机与自定义环境
|
||||
8. 可配置的构建缓存与环境缓存
|
||||
9. 本地流水线快捷验证
|
||||
可以在不借助服务器的情况下快速验证流水线语法的正确性,在借助本机容器/虚拟机的情况下进行试构建
|
||||
10. 本地文件夹推送构建
|
||||
可以推送本地的任何一个文件夹到服务器,其会根据.workshop中的规则文件/LLM自生成进行构建,支持基于哈希+分块的增量传输
|
||||
11. Vol1/2/3 用户/管理员/开发者文档
|
||||
CI/CD采用Web+CLI+Server+Multi (Agent+Baker.bin(Bakerd+Baker))设计。
|
||||
12. 最小化外部依赖
|
||||
可能且推荐的外部扩展包括:
|
||||
- RustyVault/Vault: 凭据存储
|
||||
- Koishi: IM集成与通知
|
||||
- Repology: 跨发行版包名映射
|
||||
可能的外部扩展包括:
|
||||
- SQL DB
|
||||
- NoSQL DB
|
||||
- MQ
|
||||
13. 分发
|
||||
事实上单二进制是与有系统依赖相对的。分发时,server/agent/baker需要动态下载。
|
||||
除去官方分发(软件包或者统一软件包(如flatpak))外,通过curl+sh下载验证并安装server。
|
||||
server自动下载并验证正确的agent与baker,agent/baker可缓存。
|
||||
提供agent.tar与baker.tar便于离线部署
|
||||
@@ -0,0 +1 @@
|
||||
book
|
||||
@@ -0,0 +1,5 @@
|
||||
[book]
|
||||
title = "HoneyBiscuitWorkshop Documentation"
|
||||
authors = ["Catty Steve"]
|
||||
language = "zh-CN"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# HoneyBiscuitWorkshop Documentation
|
||||
|
||||
- [中文](zh-CN/index.md)
|
||||
- [卷1 用户手册](zh-CN/vol1_user/index.md)
|
||||
- [卷2 管理员手册](zh-CN/vol2_admin/index.md)
|
||||
- [流水线隐参数](zh-CN/vol2_admin/pipeline_implicit_parameter.md)
|
||||
- [PIPELINE_UNCOVER_SECRET](zh-CN/vol2_admin/pipeline_uncover_secret.md)
|
||||
- [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 @@
|
||||
some
|
||||
@@ -0,0 +1,44 @@
|
||||
# PIPELINE_<PERMISSION_NAME>: 名称的字面意思描述
|
||||
|
||||
[一句话简介]
|
||||
|
||||
0. 预警/FOREWARN
|
||||
[警告读者可能的风险]
|
||||
|
||||
1. 概要/Synopsis
|
||||
[给出其定义与作用]
|
||||
|
||||
2. 场景/Scenario
|
||||
[给出其常见配置场景]
|
||||
|
||||
3. 描述/Description
|
||||
[给出其细节描述]
|
||||
|
||||
4. 授权/Authorize
|
||||
[给出权限的获得方法]
|
||||
|
||||
5. 撤回/Revoke
|
||||
[给出权限的撤回方法]
|
||||
|
||||
6. 生命周期/Lifecycle
|
||||
[给出权限的生命周期描述]
|
||||
|
||||
7. 审计/Audit
|
||||
[描述其对审计系统的影响,若无请忽略]
|
||||
|
||||
8. 参数/Options
|
||||
[给出可配置的参数,若无请忽略]
|
||||
|
||||
9. 建议/Suggestions
|
||||
[字面意思]
|
||||
|
||||
10. 常见问题/FAQ
|
||||
[给出用户提出的问题。此时不应该填充任何内容。]
|
||||
|
||||
11~90. 自定义字段
|
||||
|
||||
98. 作者/Author
|
||||
[字面意思,LLM的使用应该也在这里标注]
|
||||
|
||||
99. 参见/See Also
|
||||
[给出可能有关的页面]
|
||||
@@ -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 @@
|
||||
meow?
|
||||
@@ -0,0 +1,184 @@
|
||||
# PIPELINE_BAREMETAL_ELEVATE:裸机环境完整提权
|
||||
|
||||
> [!WARNING]
|
||||
> 此权限允许裸机流水线以完整 root 权限运行,可执行任意系统级操作。
|
||||
> 滥用可能导致系统崩溃、数据永久丢失、安全入侵或合规违规。
|
||||
> **仅在绝对必要时使用,必须经过严格审批、2FA 验证并记录完整审计。**
|
||||
|
||||
## 1. 概要
|
||||
|
||||
PIPELINE_BAREMETAL_ELEVATE 是一个流水线隐参数,用于**在裸机环境下临时授予流水线完整的 root 权限**。当该参数生效时,流水线可以执行任何系统级操作,包括修改系统配置、安装软件、操作内核模块等。
|
||||
|
||||
**此参数默认不开启,需要管理员通过严格流程审批并授权。**
|
||||
|
||||
## 2. 场景
|
||||
|
||||
| 适用场景 | 说明 |
|
||||
|----------|------|
|
||||
| 内核模块编译与加载 | 需要安装 kernel headers 并动态加载模块 |
|
||||
| 系统级性能调优 | 修改 sysctl 参数、CPU 调频策略、IRQ 亲和性 |
|
||||
| 硬件设备配置 | 配置 PCIe 直通、GPU 驱动安装、FPGA 编程 |
|
||||
| 系统镜像定制 | 修改根文件系统、引导配置 |
|
||||
| 遗留软件兼容 | 某些老旧软件硬编码需要 root 运行 |
|
||||
|
||||
| 不适用场景 | 说明 |
|
||||
|------------|------|
|
||||
| 普通软件包安装 | 请用 PIPELINE_BAREMETAL_PKGMAN |
|
||||
| 语言级依赖 | 请用 DepsUser 阶段的包管理器 |
|
||||
| 容器化构建 | 不需要裸机提权 |
|
||||
| 日常构建 | 正常流水线不应使用 root |
|
||||
|
||||
## 3. 描述
|
||||
|
||||
### 技术本质
|
||||
|
||||
PIPELINE_BAREMETAL_ELEVATE 通过临时切换进程凭据实现完整提权:
|
||||
|
||||
1. 在 DepsSystem 阶段开始前,baker 进程的 uid 临时切换为 0 (root)
|
||||
2. 所有子进程继承 root 权限
|
||||
3. 可执行任何系统调用、访问任何文件、修改任何配置
|
||||
4. DepsSystem 阶段结束后自动降权回普通用户
|
||||
5. 提权期间的所有操作都被强制审计
|
||||
|
||||
### 边界
|
||||
|
||||
| 允许的操作 | 不允许的操作 |
|
||||
|------------|--------------|
|
||||
| 安装/卸载系统软件包 | 修改系统引导项(超出流水线生命周期) |
|
||||
| 修改系统配置文件 | 永久关闭 SELinux/AppArmor |
|
||||
| 加载/卸载内核模块 | 安装持久化后门或定时任务 |
|
||||
| 操作其他用户的文件 | 删除其他用户的流水线数据 |
|
||||
| 修改系统服务状态 | 修改审计系统配置 |
|
||||
| 访问硬件设备 | 覆盖系统关键二进制 |
|
||||
|
||||
### 与其他参数的关系
|
||||
|
||||
| 参数 | 关系 |
|
||||
|------|------|
|
||||
| PIPELINE_BAREMETAL_PKGMAN | ELEVATE 包含 PKGMAN 的所有能力,两者互斥使用 |
|
||||
| PIPELINE_UNCOVER_SECRET | 可同时使用,但风险叠加,需特别审批 |
|
||||
| PIPELINE_CONTAINER_PRIVILEGED | 适用于不同环境,无直接关联 |
|
||||
|
||||
## 4. 授权
|
||||
|
||||
| 方式 | 适用场景 | 操作示例 |
|
||||
|------|----------|----------|
|
||||
| 管理员 CLI + 2FA | 紧急生产问题 | |
|
||||
| 审批系统集成 | 企业级合规流程 | |
|
||||
| 双人授权 | 高安全环境 | |
|
||||
|
||||
**授权流程**:
|
||||
|
||||
1. 用户或管理员发起授权请求,必须填写详细理由
|
||||
2. 系统要求发起人完成 2FA 验证
|
||||
3. 需要至少一名其他管理员二次审批(可选,可配置)
|
||||
4. 授权确认后,系统生成短期令牌随流水线下发
|
||||
|
||||
**授权确认示例**:
|
||||
```
|
||||
🚨 高危操作授权请求 🚨
|
||||
|
||||
正在申请 PIPELINE_BAREMETAL_ELEVATE 权限
|
||||
|
||||
流水线: pipeline-12345
|
||||
申请人: user@example.com
|
||||
理由: 需要安装自定义内核模块并调优 NUMA 参数
|
||||
|
||||
风险确认:
|
||||
- 此权限允许完整 root 访问
|
||||
- 可能造成系统不稳定或数据丢失
|
||||
- 所有操作将被强制审计
|
||||
|
||||
2FA 验证: ******
|
||||
|
||||
请输入 "I UNDERSTAND THE RISKS" 确认:
|
||||
>
|
||||
```
|
||||
|
||||
## 5. 撤回
|
||||
|
||||
| 撤回方式 | 说明 |
|
||||
|----------|------|
|
||||
| 手动撤回 | |
|
||||
| 自动失效 | DepsSystem 阶段结束后自动降权 |
|
||||
| 流水线结束 | 流水线结束时强制回收权限 |
|
||||
| 强制回收 | 审计系统检测到异常行为时可立即终止流水线 |
|
||||
| 超时回收 | 超过配置的最大提权时长后自动降权 |
|
||||
|
||||
## 6. 生命周期
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| PENDING | 等待审批 |
|
||||
| APPROVED | 已审批通过 |
|
||||
| ACTIVE | 提权生效中 |
|
||||
| EXPIRED | 超过最大时长或阶段结束 |
|
||||
| REVOKED | 被管理员强制撤回 |
|
||||
| AUDITED | 已完成审计归档 |
|
||||
|
||||
## 7. 审计
|
||||
|
||||
所有提权期间的操作都会被强制记录,且日志不可篡改:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `event` | GRANTED / COMMAND_EXECUTED / FILE_ACCESS / EXPIRE |
|
||||
| `user` | 申请人 |
|
||||
| `approvers` | 审批人列表 |
|
||||
| `pipeline_id` | 关联流水线 ID |
|
||||
| `timestamp` | 操作时间 |
|
||||
| `command` | 执行的完整命令行 |
|
||||
| `working_dir` | 执行路径 |
|
||||
| `exit_code` | 命令执行结果 |
|
||||
| `accessed_files` | 访问的关键文件列表 |
|
||||
| `syscalls` | 关键系统调用记录 |
|
||||
|
||||
**日志示例**:
|
||||
```json
|
||||
{
|
||||
"timestamp": "",
|
||||
"event": "",
|
||||
"pipeline_id": "",
|
||||
"user": "",
|
||||
"command": ""
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 参数
|
||||
|
||||
### 全局配置
|
||||
|
||||
```toml
|
||||
[security.baremetal_elevate]
|
||||
enabled = false # 默认关闭
|
||||
require_2fa = true # 必须 2FA
|
||||
require_dual_approval = true # 需要双人审批
|
||||
max_duration = "30m" # 最长提权时间
|
||||
allowed_stages = ["DepsSystem"] # 允许提权的阶段
|
||||
audit_log = true # 强制审计
|
||||
notify_channels = ["email", "slack"] # 通知渠道
|
||||
```
|
||||
|
||||
### 运行时参数
|
||||
|
||||
|
||||
|
||||
## 9. 建议
|
||||
|
||||
### 对用户
|
||||
|
||||
|
||||
|
||||
### 对管理员
|
||||
|
||||
|
||||
|
||||
## 10. 常见问题
|
||||
|
||||
|
||||
|
||||
## 98. 作者
|
||||
|
||||
|
||||
|
||||
## 99. 参见
|
||||
@@ -0,0 +1,161 @@
|
||||
# PIPELINE_BAREMETAL_PKGMAN:裸机环境包管理器权限
|
||||
|
||||
> [!WARNING]
|
||||
> 此权限允许裸机流水线在 DepsSystem 阶段临时使用系统包管理器(需要 root 权限的操作)。
|
||||
> 滥用可能导致系统环境被破坏或意外安装恶意软件包。
|
||||
> **非必要不使用,使用时必须经过管理员审批并记录审计。**
|
||||
|
||||
## 1. 概要
|
||||
|
||||
PIPELINE_BAREMETAL_PKGMAN 是一个流水线隐参数,用于**在裸机环境下临时授予 DepsSystem 阶段使用系统包管理器的权限**。当该参数生效时,流水线可以执行 apt-get、pacman、yum 等包管理器命令安装构建依赖,但**不授予完整的 root 权限**。
|
||||
|
||||
**此参数默认不开启,需要管理员显式授予。**
|
||||
|
||||
## 2. 场景
|
||||
|
||||
| 适用场景 | 说明 |
|
||||
|----------|------|
|
||||
| 系统依赖安装 | 构建过程需要 gcc、make、openssl-dev 等系统包 |
|
||||
| 基础环境准备 | 裸机环境缺少必要的构建工具链 |
|
||||
| 内核模块编译 | 需要安装 kernel headers 等特权操作 |
|
||||
| 性能调优工具 | 某些性能分析工具需要系统级安装 |
|
||||
|
||||
| 不适用场景 | 说明 |
|
||||
|------------|------|
|
||||
| 任意命令执行 | 请用 PIPELINE_BAREMETAL_ELEVATE |
|
||||
| 容器化构建 | 不需要裸机包管理器 |
|
||||
| 语言级依赖 | 请用 DepsUser 阶段的 cargo/pip/npm |
|
||||
|
||||
## 3. 描述
|
||||
|
||||
### 技术本质
|
||||
|
||||
PIPELINE_BAREMETAL_PKGMAN 通过 sudoers 白名单实现受限提权:
|
||||
|
||||
1. 系统在 DepsSystem 阶段开始前,为 baker 配置临时 sudo 规则
|
||||
2. 仅允许执行预设的包管理器命令列表
|
||||
3. 所有命令执行都被审计记录
|
||||
4. DepsSystem 阶段结束后自动移除 sudo 权限
|
||||
|
||||
### 边界
|
||||
|
||||
| 允许的操作 | 不允许的操作 |
|
||||
|------------|--------------|
|
||||
| apt-get install | 任意 shell 命令 |
|
||||
| pacman -S | 修改系统配置文件 |
|
||||
| yum install | 操作其他用户的文件 |
|
||||
| dnf install | 加载内核模块 |
|
||||
| zypper install | 安装持久化服务 |
|
||||
| 包管理器缓存更新 | 修改系统引导项 |
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 需要系统启用 `security.baremetal_pkgman.enabled`(默认开启)
|
||||
- 与其他隐参数(如 UNCOVER_SECRET)可同时使用,但需评估叠加风险
|
||||
- 与 BAREMETAL_ELEVATE 互斥(后者已包含包管理器权限)
|
||||
|
||||
## 4. 授权
|
||||
|
||||
| 方式 | 适用场景 | 操作示例 |
|
||||
|------|----------|----------|
|
||||
| 管理员 CLI | 临时授权 | |
|
||||
| 审批系统集成 | 企业流程 | |
|
||||
| 用户申请 | 常规需求 | |
|
||||
|
||||
**授权流程**:
|
||||
|
||||
1. 管理员或用户发起授权请求
|
||||
2. 系统验证请求者权限
|
||||
3. 授权确认后,参数随流水线下发
|
||||
|
||||
**授权确认示例**:
|
||||
```
|
||||
⚠️ 正在授予 PIPELINE_BAREMETAL_PKGMAN 权限
|
||||
|
||||
流水线: pipeline-12345
|
||||
申请人: user@example.com
|
||||
理由: 需要安装 gcc、make 等构建依赖
|
||||
|
||||
此权限允许:
|
||||
- 在 DepsSystem 阶段使用包管理器
|
||||
- 仅限预设命令列表
|
||||
- 不授予其他 root 权限
|
||||
|
||||
确认授予?(y/N)
|
||||
```
|
||||
|
||||
## 5. 撤回
|
||||
|
||||
| 撤回方式 | 说明 |
|
||||
|----------|------|
|
||||
| 手动撤回 | |
|
||||
| 自动失效 | DepsSystem 阶段结束后自动回收 |
|
||||
| 流水线结束 | 流水线结束时权限自动回收 |
|
||||
| 强制回收 | 审计系统检测到异常时可终止流水线 |
|
||||
|
||||
## 6. 生命周期
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| PENDING | 等待审批 |
|
||||
| ACTIVE | 已授权,DepsSystem 阶段可用 |
|
||||
| EXPIRED | 阶段结束或流水线结束 |
|
||||
| REVOKED | 被管理员强制撤回 |
|
||||
|
||||
## 7. 审计
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `event` | GRANTED / COMMAND_EXECUTED / EXPIRED / REVOKED |
|
||||
| `user` | 申请人 |
|
||||
| `pipeline_id` | 关联流水线 ID |
|
||||
| `timestamp` | 操作时间 |
|
||||
| `command` | 执行的包管理器命令 |
|
||||
| `exit_code` | 命令执行结果 |
|
||||
|
||||
**日志示例**:
|
||||
```json
|
||||
{
|
||||
"timestamp": "",
|
||||
"event": "",
|
||||
"pipeline_id": "",
|
||||
"user": ""
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 参数
|
||||
|
||||
### 全局配置
|
||||
|
||||
```toml
|
||||
[security.baremetal_pkgman]
|
||||
enabled = true
|
||||
require_approval = true
|
||||
default_timeout = "30m"
|
||||
allowed_commands = ["apt-get", "pacman", "yum", "dnf", "zypper"]
|
||||
audit_log = true
|
||||
```
|
||||
|
||||
### 运行时参数
|
||||
|
||||
|
||||
|
||||
## 9. 建议
|
||||
|
||||
### 对用户
|
||||
|
||||
|
||||
|
||||
### 对管理员
|
||||
|
||||
|
||||
|
||||
## 10. 常见问题
|
||||
|
||||
|
||||
|
||||
## 98. 作者
|
||||
|
||||
|
||||
|
||||
## 99. 参见
|
||||
@@ -0,0 +1,187 @@
|
||||
# PIPELINE_CONTAINER_PRIVILEGED:容器环境特权模式
|
||||
|
||||
> [!WARNING]
|
||||
> 此权限允许容器以 privileged 模式运行,使容器内的进程拥有接近宿主机的权限。
|
||||
> 滥用可能导致容器逃逸、宿主机被入侵或影响其他容器。
|
||||
> **仅在绝对必要时使用,必须经过严格审批并记录完整审计。**
|
||||
|
||||
## 1. 概要
|
||||
|
||||
PIPELINE_CONTAINER_PRIVILEGED 是一个流水线隐参数,用于**在容器环境下授予流水线 privileged 权限**。当该参数生效时,容器将获得所有 capabilities,并可以访问宿主机的所有设备,能够执行需要特权的操作如挂载文件系统、加载内核模块等。
|
||||
|
||||
**此参数默认不开启,需要管理员通过严格流程审批并授权。**
|
||||
|
||||
## 2. 场景
|
||||
|
||||
| 适用场景 | 说明 |
|
||||
|----------|------|
|
||||
| Docker in Docker | 需要在容器内运行 Docker 命令构建镜像 |
|
||||
| FUSE 文件系统 | 需要在容器内挂载用户态文件系统 |
|
||||
| 内核模块测试 | 需要在容器内加载/测试内核模块 |
|
||||
| 系统调用调试 | 需要 ptrace 或其他特权系统调用 |
|
||||
| 性能分析工具 | 需要访问 perf、systemtap 等性能工具 |
|
||||
| 硬件设备访问 | 需要直接访问 GPU、FPGA 或其他硬件设备 |
|
||||
|
||||
| 不适用场景 | 说明 |
|
||||
|------------|------|
|
||||
| 普通软件包安装 | 请用容器镜像预装或 DepsUser 阶段 |
|
||||
| 构建普通应用 | 不需要特权模式 |
|
||||
| 网络抓包调试 | 请用容器网络配置而非特权模式 |
|
||||
|
||||
## 3. 描述
|
||||
|
||||
### 技术本质
|
||||
|
||||
PIPELINE_CONTAINER_PRIVILEGED 通过修改容器运行时配置实现提权:
|
||||
|
||||
1. 容器启动时添加 `--privileged` 标志
|
||||
2. 容器内的 root 用户拥有宿主机 root 的完整 capabilities
|
||||
3. 容器可以访问宿主机所有设备(`/dev/*`)
|
||||
4. 可以执行通常被容器运行时限制的操作(如 mount)
|
||||
5. 权限仅在该容器生命周期内有效,容器销毁后自动回收
|
||||
|
||||
### 与 CAP_ADD 的区别
|
||||
|
||||
| 模式 | 说明 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| PRIVILEGED | 全部 capabilities + 设备访问 | 需要完整系统级访问 |
|
||||
| CAP_ADD | 添加特定 capabilities | 仅需个别特权操作 |
|
||||
|
||||
### 边界
|
||||
|
||||
| 允许的操作 | 不允许的操作 |
|
||||
|------------|--------------|
|
||||
| 挂载文件系统 | 修改宿主机关键配置 |
|
||||
| 运行 Docker 命令 | 关闭宿主机审计系统 |
|
||||
| 加载内核模块 | 删除其他容器数据 |
|
||||
| 访问宿主机设备 | 修改宿主机网络配置 |
|
||||
| 执行 ptrace 调试 | 安装持久化后门 |
|
||||
| 配置网络命名空间 | 覆盖宿主机二进制 |
|
||||
|
||||
### 与其他参数的关系
|
||||
|
||||
| 参数 | 关系 |
|
||||
|------|------|
|
||||
| PIPELINE_CONTAINER_CAP_ADD | 互补关系,PRIVILEGED 是更粗粒度的权限 |
|
||||
| PIPELINE_UNCOVER_SECRET | 可同时使用,需评估叠加风险 |
|
||||
| PIPELINE_BAREMETAL_ELEVATE | 适用于不同环境,无直接关联 |
|
||||
|
||||
## 4. 授权
|
||||
|
||||
| 方式 | 适用场景 | 操作示例 |
|
||||
|------|----------|----------|
|
||||
| 管理员 CLI | 临时授权 | |
|
||||
| 审批系统集成 | 企业流程 | |
|
||||
| 用户申请 | 常规需求 | |
|
||||
|
||||
**授权流程**:
|
||||
|
||||
1. 管理员或用户发起授权请求
|
||||
2. 系统验证请求者权限并记录理由
|
||||
3. 授权确认后,参数随容器启动下发给运行时
|
||||
4. 容器销毁后权限自动回收
|
||||
|
||||
**授权确认示例**:
|
||||
```
|
||||
⚠️ 正在授予 PIPELINE_CONTAINER_PRIVILEGED 权限
|
||||
|
||||
容器: pipeline-12345-container
|
||||
申请人: user@example.com
|
||||
理由: 需要在 CI 中构建 Docker 镜像并运行容器测试
|
||||
|
||||
此权限允许:
|
||||
- 容器以 privileged 模式运行
|
||||
- 可访问宿主机所有设备
|
||||
- 可执行 mount 等特权操作
|
||||
- 仅在该容器生命周期内有效
|
||||
|
||||
风险提示:
|
||||
- 可能造成容器逃逸
|
||||
- 可能影响宿主机稳定性
|
||||
- 所有操作将被强制审计
|
||||
|
||||
确认授予?(y/N)
|
||||
```
|
||||
|
||||
## 5. 撤回
|
||||
|
||||
| 撤回方式 | 说明 |
|
||||
|----------|------|
|
||||
| 手动撤回 | |
|
||||
| 自动失效 | 容器停止时权限自动回收 |
|
||||
| 流水线结束 | 流水线结束时强制销毁容器 |
|
||||
| 强制回收 | 审计系统检测到异常时可终止容器 |
|
||||
| 超时回收 | 超过配置的最大时长后自动终止容器 |
|
||||
|
||||
## 6. 生命周期
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| PENDING | 等待审批 |
|
||||
| ACTIVE | 容器以特权模式运行中 |
|
||||
| EXPIRED | 容器已停止 |
|
||||
| REVOKED | 被管理员强制终止 |
|
||||
|
||||
## 7. 审计
|
||||
|
||||
所有特权容器内的关键操作都会被记录:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `event` | GRANTED / CONTAINER_START / CONTAINER_STOP / COMMAND_EXECUTED |
|
||||
| `user` | 申请人 |
|
||||
| `pipeline_id` | 关联流水线 ID |
|
||||
| `container_id` | 容器 ID |
|
||||
| `timestamp` | 操作时间 |
|
||||
| `command` | 执行的命令 |
|
||||
| `mounts` | 挂载的操作 |
|
||||
| `devices_accessed` | 访问的设备 |
|
||||
|
||||
**日志示例**:
|
||||
```json
|
||||
{
|
||||
"timestamp": "",
|
||||
"event": "",
|
||||
"pipeline_id": "",
|
||||
"container_id": "",
|
||||
"user": ""
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 参数
|
||||
|
||||
### 全局配置
|
||||
|
||||
```toml
|
||||
[security.container_privileged]
|
||||
enabled = false # 默认关闭
|
||||
require_approval = true # 需要审批
|
||||
max_duration = "60m" # 最大运行时间
|
||||
allowed_images = [] # 允许特权运行的镜像白名单
|
||||
audit_log = true # 强制审计
|
||||
notify_on_start = true # 启动时通知管理员
|
||||
```
|
||||
|
||||
### 运行时参数
|
||||
|
||||
|
||||
|
||||
## 9. 建议
|
||||
|
||||
### 对用户
|
||||
|
||||
|
||||
|
||||
### 对管理员
|
||||
|
||||
|
||||
|
||||
## 10. 常见问题
|
||||
|
||||
|
||||
|
||||
## 98. 作者
|
||||
|
||||
|
||||
|
||||
## 99. 参见
|
||||
@@ -0,0 +1,12 @@
|
||||
# 流水线隐参数
|
||||
|
||||
流水线隐参数是 server 在运行时根据权限、环境、策略动态生成的参数,
|
||||
随流水线定义一起下发给执行组件。
|
||||
|
||||
常见隐参数:
|
||||
- `PIPELINE_BAREMETAL_PKGMAN`: 允许裸机环境使用包管理器(需 root)
|
||||
- `PIPELINE_CONTAINER_PRIVILEGED`: 允许容器以 privileged 模式运行
|
||||
- `PIPELINE_UNCOVER_SECRET`: 允许输出 secret 明文
|
||||
|
||||
这些参数不在 prebake.yml 中配置,由 server 管理,
|
||||
并随流水线生命周期自动生效和废弃。
|
||||
@@ -0,0 +1,198 @@
|
||||
# PIPELINE_UNCOVER_SECRET:临时暴露敏感信息
|
||||
|
||||
> [!WARNING]
|
||||
> 此权限允许流水线临时输出所有 secret 的明文,包括环境变量、配置文件、构建日志中的敏感信息。
|
||||
> 滥用可能导致凭据泄露、数据泄露或安全入侵。
|
||||
> **非必要不使用,使用时必须经过 2FA 验证并记录审计。**
|
||||
|
||||
## 1. 概要
|
||||
|
||||
PIPELINE_UNCOVER_SECRET 是一个流水线隐参数,用于**临时解除系统对敏感信息的默认保护**。当该参数生效时,原本会被遮蔽的 secret 值将以明文形式出现在日志、标准输出和构建产物中,便于调试、审计或教学。
|
||||
|
||||
**此参数默认不开启,需要用户通过 2FA 显式申请并获得临时授权。**
|
||||
|
||||
## 2. 场景
|
||||
|
||||
| 适用场景 | 说明 |
|
||||
|----------|------|
|
||||
| 调试构建失败 | 怀疑 secret 传递错误,需要确认环境变量实际值 |
|
||||
| 合规性审计 | 需要验证 secret 是否正确配置和使用 |
|
||||
| 教学演示 | 展示 CI/CD 流程中 secret 的完整生命周期 |
|
||||
| 遗留系统兼容 | 某些老旧脚本硬编码依赖 echo secret 的方式工作 |
|
||||
|
||||
|
||||
|
||||
| 不适用场景 | 说明 |
|
||||
|------------|------|
|
||||
| 日常构建 | 正常流水线不应暴露 secret |
|
||||
| 生产环境调试 | 请先在预发环境复现问题 |
|
||||
| 权限测试 | 请用专门的测试账号 |
|
||||
|
||||
## 3. 描述
|
||||
|
||||
### 技术本质
|
||||
|
||||
PIPELINE_UNCOVER_SECRET 通过以下机制实现:
|
||||
|
||||
1. 在流水线启动时,系统生成一个临时令牌并注入 baker 环境
|
||||
2. baker 在运行期间检测到该令牌,**暂停所有输出遮蔽逻辑**
|
||||
3. 所有敏感变量(包括环境变量、文件内容、构建日志)都以原始形式输出
|
||||
4. 权限到期后,系统自动恢复遮蔽保护
|
||||
|
||||
### 边界
|
||||
|
||||
| 允许的操作 | 不允许的操作 |
|
||||
|------------|--------------|
|
||||
| 查看 secret 明文 | 将 secret 持久化到外部系统 |
|
||||
| 输出 secret 到日志 | 关闭审计日志 |
|
||||
| 调试脚本中的 secret 使用 | 延长权限有效期 |
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 需要用户已配置 **2FA**
|
||||
- 需要系统启用 `security.uncover_secret.enabled`(默认开启)
|
||||
- 与其他隐参数(如 BAREMETAL_ELEVATE)互斥使用时需注意叠加风险
|
||||
|
||||
## 4. 授权
|
||||
|
||||
| 方式 | 适用场景 | 操作示例 |
|
||||
|------|----------|----------|
|
||||
| 用户自申请 | 调试/审计 | `hbw pipeline run --uncover-secret` |
|
||||
| 紧急授权 | 生产问题 | 需 2FA + 管理员二次确认 |
|
||||
|
||||
**授权流程**:
|
||||
|
||||
1. 用户发起请求时附加 `--uncover-secret` 标志
|
||||
2. 系统要求输入 2FA 验证码
|
||||
3. 验证通过后显示警告并要求确认
|
||||
4. 用户确认后,系统生成有效期 5 分钟的临时令牌
|
||||
|
||||
**授权确认示例**:
|
||||
```
|
||||
Request for PIPELINE_UNCOVER_SECRET
|
||||
2FA Code: ******
|
||||
|
||||
WARNING: You are about to expose ALL secrets in this pipeline.
|
||||
Sensitive data may appear in logs, outputs, and artifacts.
|
||||
This operation is audited. Continue? (y/N)
|
||||
```
|
||||
|
||||
## 5. 撤回
|
||||
|
||||
| 撤回方式 | 说明 |
|
||||
|----------|------|
|
||||
| 手动撤回 | 管理员可执行 `hbw admin revoke-uncover pipeline-12345` 强制终止 |
|
||||
| 自动失效 | 5 分钟有效期到达后自动恢复遮蔽 |
|
||||
| 流水线结束 | 无论是否到期,流水线结束时权限自动回收 |
|
||||
| 强制回收 | 审计系统检测到异常行为时可自动终止流水线并回收权限 |
|
||||
|
||||
## 6. 生命周期
|
||||
|
||||
```
|
||||
申请 → 2FA验证 → 确认 → 授权 → 使用 → 失效
|
||||
↑ ↑ ↑ ↑ ↑ ↑
|
||||
用户 系统 用户 系统 流水线 时间/事件
|
||||
```
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| PENDING | 等待 2FA 验证 |
|
||||
| ACTIVE | 已授权,secret 可明文输出 |
|
||||
| EXPIRED | 超过 5 分钟有效期 |
|
||||
| REVOKED | 被管理员或系统强制撤回 |
|
||||
|
||||
## 7. 审计
|
||||
|
||||
所有涉及 PIPELINE_UNCOVER_SECRET 的操作都会被详细记录:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `event` | GRANTED / USED / EXPIRED / REVOKED |
|
||||
| `user` | 操作人邮箱 |
|
||||
| `pipeline_id` | 关联流水线 ID |
|
||||
| `timestamp` | 操作时间 |
|
||||
| `auth_method` | 2FA |
|
||||
| `expires_at` | 授权到期时间 |
|
||||
| `accessed_secrets` | 授权期间访问过的 secret 列表 |
|
||||
|
||||
**日志示例**:
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-03-19T10:23:45Z",
|
||||
"event": "PIPELINE_UNCOVER_SECRET_GRANTED",
|
||||
"user": "developer@example.com",
|
||||
"pipeline_id": "pl-12345",
|
||||
"auth_method": "2FA",
|
||||
"expires_at": "2026-03-19T10:28:45Z"
|
||||
}
|
||||
|
||||
{
|
||||
"timestamp": "2026-03-19T10:25:12Z",
|
||||
"event": "SECRET_ACCESSED",
|
||||
"pipeline_id": "pl-12345",
|
||||
"variable": "DOCKER_PASSWORD",
|
||||
"access_type": "ENV_VAR_READ"
|
||||
}
|
||||
```
|
||||
|
||||
管理员可通过审计日志追溯:
|
||||
- 谁在什么时候申请了该权限
|
||||
- 授权给了哪个流水线
|
||||
- 哪些 secret 在授权期间被访问
|
||||
- 权限是否在有效期内
|
||||
|
||||
## 8. 参数
|
||||
|
||||
### 全局配置 (bakerd.toml)
|
||||
|
||||
```toml
|
||||
[security.uncover_secret]
|
||||
enabled = true # 是否允许使用该功能
|
||||
default_timeout = "5m" # 默认授权时长
|
||||
require_2fa = true # 是否强制 2FA
|
||||
audit_log = true # 是否记录审计日志
|
||||
max_concurrent_per_user = 1 # 每个用户同时最多可授权几个流水线
|
||||
```
|
||||
|
||||
### 运行时参数
|
||||
|
||||
```bash
|
||||
# 申请权限
|
||||
hbw pipeline run --uncover-secret
|
||||
|
||||
# 指定超时(需管理员权限)
|
||||
hbw pipeline run --uncover-secret --uncover-timeout 10m
|
||||
|
||||
# 带理由(便于审计)
|
||||
hbw pipeline run --uncover-secret --reason "调试数据库连接失败"
|
||||
```
|
||||
|
||||
## 9. 建议
|
||||
|
||||
### 对用户
|
||||
|
||||
- **非必要不使用**:能用常规调试手段解决的问题,不要依赖暴露 secret
|
||||
- **用完后立即结束流水线**:避免权限窗口被意外延长
|
||||
- **检查日志**:使用结束后检查是否有 secret 被意外记录到持久化存储中
|
||||
- **轮换凭据**:如果确认 secret 在授权期间被暴露,建议立即轮换相关凭据
|
||||
|
||||
### 对管理员
|
||||
|
||||
- **定期审计**:每周检查 UNCOVER_SECRET 使用记录,发现异常及时处理
|
||||
- **设置合理超时**:根据团队需求调整默认超时时间(建议不超过 15 分钟)
|
||||
- **培训用户**:确保团队成员理解该权限的风险和使用场景
|
||||
- **与审批系统集成**:对高风险操作增加二次审批流程
|
||||
|
||||
## 10. 常见问题
|
||||
|
||||
|
||||
|
||||
## 98. 作者
|
||||
|
||||
```yaml
|
||||
---
|
||||
authors:
|
||||
---
|
||||
```
|
||||
|
||||
## 99. 参见
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
# 卷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
Submodule rust-tongsuo deleted from 6248690a45
@@ -0,0 +1,85 @@
|
||||
#!/bin/bash
|
||||
|
||||
#Implicit set -euo pipefail
|
||||
|
||||
# function to be used by any pipeline stage
|
||||
function get_custom_library() {
|
||||
curl -LO https://archive.ppy.sh/$2/$1.tar.gz
|
||||
}
|
||||
|
||||
# @pipeline
|
||||
prepare_dotnet() {
|
||||
curl -L https://dot.net/v1/dotnet-install.sh | bash
|
||||
bake_info "Successfully installed dotnet"
|
||||
}
|
||||
|
||||
# @pipeline
|
||||
fetch_source() {
|
||||
git clone https://github.com/ppy/osu.git --depth 1
|
||||
}
|
||||
|
||||
# @pipeline
|
||||
# @if(WS_PIPELINE_ARCH == amd64)
|
||||
# @timeout(720)
|
||||
# @retry(3, 5)
|
||||
# @parallel
|
||||
fetch_library() {
|
||||
get_custom_library ffmpeg amd64
|
||||
get_custom_library libesqlite3 amd64
|
||||
get_custom_library libbass amd64
|
||||
}
|
||||
|
||||
|
||||
# @pipeline
|
||||
# @fallible
|
||||
# @parallel
|
||||
fetch_osu_framework() {
|
||||
cd ..
|
||||
git clone https://github.com/ppy/osu-framework.git --depth 1
|
||||
}
|
||||
|
||||
# @pipeline
|
||||
# @timeout(600)
|
||||
restore_workload() {
|
||||
cd osu
|
||||
dotnet workload restore
|
||||
}
|
||||
|
||||
# @pipeline
|
||||
# @export
|
||||
set_environment() {
|
||||
export DOTNET_ENVIRONMENT=Production
|
||||
}
|
||||
|
||||
# @pipeline
|
||||
# @export
|
||||
build() {
|
||||
dotnet build osu.Desktop
|
||||
dotnet publish osu.Desktop -o osu-build --sc
|
||||
}
|
||||
|
||||
# @pipeline
|
||||
# @loop(3)
|
||||
test() {
|
||||
dotnet test
|
||||
}
|
||||
|
||||
# @pipeline
|
||||
# @parallel
|
||||
# @after(fetch_library)
|
||||
compress_library() {
|
||||
mkdir -p osu-native
|
||||
cp osu-build/*.so ../osu-native
|
||||
tar -cf osu-native osu-native.tar
|
||||
zstd osu.tar
|
||||
}
|
||||
|
||||
# @pipeline
|
||||
# @after(test)
|
||||
compress_artifact() {
|
||||
tar -cf osu osu.tar
|
||||
zstd osu.tar
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
# finalize.yml.tmpl
|
||||
#
|
||||
# 蜜饼工坊 (HoneyBiscuitWorkshop) 构建流水线 - 完成阶段配置
|
||||
#
|
||||
# 版本号,用于格式兼容性检查
|
||||
# 版本迁移系统内置,预期 1.0 后不再变更 schema
|
||||
version: "0.0.1"
|
||||
|
||||
# =============================================================================
|
||||
# 插件定义
|
||||
# =============================================================================
|
||||
# 插件在 finalize 阶段执行特定任务(如推送镜像、上传制品等)
|
||||
# 插件可以是 Shell 脚本或 Rhai 脚本,由文件扩展名自动检测
|
||||
#
|
||||
# 插件来源格式:
|
||||
# git://<git-url>@<tag>/<commit> - Git 仓库(在 prebake 阶段拉取)
|
||||
# https://<url> - HTTPS 下载(支持脚本直接下载)
|
||||
# file://<path> - 本地文件(在 finalize.begin 阶段加载)
|
||||
# http://<url> - HTTP 下载(计划支持)
|
||||
# hg://<url> - Mercurial 仓库(计划支持)
|
||||
#
|
||||
# 插件隔离:
|
||||
# - Docker/VM 环境:允许任意插件,由环境提供隔离
|
||||
# - Bare-metal 环境:需要额外权限配置(PIP: Pipeline Implicit Parameters)
|
||||
#
|
||||
# 插件通信:
|
||||
# - Shell 脚本:通过命令行参数和环境变量接收配置
|
||||
# - Rhai 脚本:通过注入的对象/函数接收配置(WIP)
|
||||
# - 返回值:通过退出码表示成功/失败
|
||||
# - 日志:会被捕获但不作为执行证据
|
||||
plugin:
|
||||
# 插件逻辑名称,用于在 artifact.publish 和 notification 中引用
|
||||
# 名称必须唯一,不可重复定义
|
||||
docker-push:
|
||||
# 插件来源 URI,包含版本锁定
|
||||
# @main/1234abcd 表示 main 分支的 1234abcd 提交
|
||||
# 如果该提交不在指定分支中,则执行失败
|
||||
"git://github.com/user/hbw-finalize-docker-push@main/1234abcd":
|
||||
# 认证主机,仅用于认证目的
|
||||
# 如果 publish 中指定了 image 且包含主机部分,此处可省略
|
||||
host: "docker.io"
|
||||
|
||||
# 用户凭证,支持模板变量
|
||||
# {{ variable.xxx }} 为用户配置值
|
||||
# {{ secret.xxx }} 为从 Vault 获取的密钥
|
||||
user: {{ variable.dockerhub.user }}
|
||||
token: {{ secret.dockerhub.token }}
|
||||
|
||||
# fallible: 是否允许失败
|
||||
# true - 插件失败不会导致构建失败,错误不会向上传播
|
||||
# false - 插件失败会导致构建失败
|
||||
# 语义与 bake.sh 的 @fallible 装饰器相同
|
||||
fallible: false
|
||||
|
||||
# 镜像安全检查插件示例(HTTPS 下载)
|
||||
image-check:
|
||||
"https://example.com/trivy/trivy.sh":
|
||||
user: {{ variable.trivy.user }}
|
||||
password: {{ secret.trivy.password }}
|
||||
fallible: true
|
||||
|
||||
# 短信通知插件示例(本地 Rhai 脚本)
|
||||
sms-notify:
|
||||
"file://.workshop/plugins/sms-notify.rhai":
|
||||
provider: "aliyun"
|
||||
access_key: {{ secret.aliyun.sms_key }}
|
||||
access_secret: {{ secret.aliyun.sms_secret }}
|
||||
|
||||
# =============================================================================
|
||||
# 通知配置
|
||||
# =============================================================================
|
||||
# 通知采用优先级组机制:
|
||||
# - 优先级为整数,范围 0 ~ INT_MAX
|
||||
# - 数值越小优先级越高
|
||||
# - 同一优先级组内的通知方式并行执行
|
||||
# - 当组内任一方式失败时,触发下一优先级组(fallback)
|
||||
# - fallible: true 可阻止错误传播,不触发 fallback
|
||||
#
|
||||
# 系统级 fallback(在用户配置之外):
|
||||
# 1. 站内通知(存储在数据库,显示在 Baker Dashboard)
|
||||
# 2. 系统邮件(用户创建时配置)
|
||||
# 3. 放弃通知,管理员接收告警(如已配置)
|
||||
#
|
||||
# 优先级组为空时会产生警告,但不会自动修正
|
||||
# 建议将优先级设为 0、1、2... 等连续值
|
||||
|
||||
notification:
|
||||
templates:
|
||||
sms-tmpl1:
|
||||
on_success: "Build success!"
|
||||
on_failure: "Build failed!"
|
||||
on_finish_of: "Build stage {{ build.status }} finished."
|
||||
sms-tmpl2:
|
||||
use: "git://https://gitee.com/example_user/example_template_for_sms.git@a1b2c3d4"
|
||||
|
||||
# 最高优先级组 - 首选通知方式
|
||||
0:
|
||||
# Webhook 通知
|
||||
webhook:
|
||||
url: "https://example.com/hook"
|
||||
template:
|
||||
# schema 定义负载格式:
|
||||
# - "default": 使用系统(ws-base)指定的默认格式,standalone模式不可用,client模式的默认
|
||||
# - "fallback": 使用插件指定的默认格式,standalone模式的默认
|
||||
# - "custom": 自定义格式,需要提供全部 on_* 模板
|
||||
# - "<name>": 使用templates节中定义的名字
|
||||
# 当任何on_*被设置,则等效于replace模式:schema做基底,on_*做替换
|
||||
schema: "default"
|
||||
|
||||
# Satori 协议通知(如 Koishi)
|
||||
satori:
|
||||
# TODO: 需要大量重构
|
||||
|
||||
# 次优先级组 - 备用通知方式(当优先级 0 中任一方式失败时触发)
|
||||
1:
|
||||
# 邮件通知(内置插件)
|
||||
mail:
|
||||
to: "user@gmail.com"
|
||||
# TODO: 替换为实际mail里的字段
|
||||
schema: "default"
|
||||
|
||||
# 插件也可以直接作为通知方式使用
|
||||
# 需要插件支持通知触发器(on_success/on_failure/on_finish_of)
|
||||
sms-notify:
|
||||
to: "+8610012345678"
|
||||
policy:
|
||||
- trigger:
|
||||
- on_success
|
||||
template:
|
||||
schema: "custom"
|
||||
on_success: "[HBW Notify] Build Succeeded: {{ build.status }} ({{ build.duration }})"
|
||||
to: "+8610012345678"
|
||||
- trigger:
|
||||
- on_success
|
||||
- on_failure
|
||||
template:
|
||||
schema: "sms-tmpl1"
|
||||
# on_success: "[HBW Notify] Build Succeeded: {{ build.status }} ({{ build.duration }})"
|
||||
# on_failure: "[HBW Notify] Build Failed: {{ build.status }} ({{ build.duration }})"
|
||||
to: "+8610012345679"
|
||||
- trigger:
|
||||
- on_finish_of:
|
||||
- "bake.build"
|
||||
- "bake.clean.*"
|
||||
- 're:bake\.prepare\.*'
|
||||
template:
|
||||
schema: "custom"
|
||||
on_finish_of: "[HBW Notify] Build Progress: {{ build.status }} ({{ build.duration }})"
|
||||
to: "+8610012345677"
|
||||
fallible: true
|
||||
|
||||
# =============================================================================
|
||||
# 制品定义
|
||||
# =============================================================================
|
||||
# 制品是构建产生的输出文件,可被存储、发布或清理
|
||||
#
|
||||
# 制品 ID:
|
||||
# - 可选,如未指定则由系统自动生成
|
||||
# - 格式:MD5(PUID + PATH)
|
||||
# - PUID: Pipeline Unique Identifier
|
||||
#
|
||||
# 制品路径:
|
||||
# - 支持通配符,会被编译为正则表达式
|
||||
# - 在配置加载时检查语法,在执行时编译和匹配
|
||||
# - 总限制时间 1 秒(编译 + 匹配)
|
||||
# - 一个路径匹配 = 一个制品元素
|
||||
# - 空路径表示非文件系统制品(如 Docker 镜像)
|
||||
#
|
||||
# 制品传输:
|
||||
# - 通过 hard-link/rsync 传输到插件工作空间
|
||||
# - 插件可自定义传输方式
|
||||
|
||||
artifact:
|
||||
# 示例制品 1:普通文件制品
|
||||
- # 制品 ID,可选
|
||||
# 如不指定,系统自动生成 MD5(PUID + PATH)
|
||||
id: "<SOME_ARTIFACT_ID>"
|
||||
|
||||
# 制品路径,支持 glob 模式
|
||||
# 匹配的每个文件/目录作为独立制品
|
||||
path: "/workspace/target/release/*"
|
||||
|
||||
# 保留时长
|
||||
# TODO
|
||||
retention: "7d"
|
||||
|
||||
# 压缩方式:
|
||||
# - "zstd": Zstandard 压缩(推荐)
|
||||
# - "gzip": Gzip 压缩
|
||||
# - "none": 不压缩
|
||||
# - "dir": 保留为目录结构(用于需要完整目录树的场景)
|
||||
compression: "zstd"
|
||||
|
||||
# 触发条件:
|
||||
# - "success": 构建成功时处理
|
||||
# - "failure": 构建失败时处理(WIP)
|
||||
# - "always": 总是处理(WIP)
|
||||
# 支持多个条件
|
||||
on:
|
||||
- success
|
||||
|
||||
# 示例制品 2:Docker 镜像制品
|
||||
- id: "<ANOTHER_ARTIFACT_ID>"
|
||||
|
||||
# 空路径表示非文件系统制品
|
||||
# 用于 Docker 镜像、远程制品等
|
||||
# 此类制品的"内容"由 publish 定义
|
||||
path: ""
|
||||
|
||||
retention: "5d"
|
||||
compression: "none"
|
||||
|
||||
on:
|
||||
- success
|
||||
|
||||
# 发布配置
|
||||
# 制品可以发布到一个或多个目标
|
||||
# 多个发布目标并行执行(除非受 Token 约束)
|
||||
publish:
|
||||
# 引用 plugin 部分定义的插件名称
|
||||
docker-push:
|
||||
# 覆盖/补充插件配置
|
||||
# 镜像名称,包含 registry 地址
|
||||
# 如 plugin 中已定义 host,此处可省略 registry 部分
|
||||
image: "docker.io/user/image:tag"
|
||||
|
||||
# 同一制品可以发布到多个插件
|
||||
# 通过 Token/Lock 机制协调访问
|
||||
image-check:
|
||||
image: "docker.io/user/image:tag"
|
||||
|
||||
# =============================================================================
|
||||
# 钩子定义
|
||||
# =============================================================================
|
||||
# 在 finalize 阶段执行的前置/后置钩子
|
||||
# 可用于清理、预处理、后处理等操作
|
||||
#
|
||||
# 执行时机:
|
||||
# - early: 制品收集之前执行
|
||||
# - late: 所有操作完成后执行
|
||||
#
|
||||
# 命令类型:
|
||||
# - 普通命令:直接执行 shell 命令
|
||||
# - 插件命令:以 "plugin:" 开头,调用已定义的插件
|
||||
|
||||
hooks:
|
||||
# 早期钩子:在制品收集之前执行
|
||||
early:
|
||||
- # 钩子名称,用于日志和调试
|
||||
name: "cleanup temp files before artifact collection"
|
||||
# 执行命令
|
||||
command: "rm **/.tmp"
|
||||
# 工作目录
|
||||
working_dir: "/workspace"
|
||||
|
||||
# 插件类型钩子示例
|
||||
- name: "Early hook internal plugin"
|
||||
command: "plugin:early-hook-internal"
|
||||
# 超时时间(秒)
|
||||
timeout: 600
|
||||
|
||||
# 后期钩子:在所有操作完成后执行
|
||||
late:
|
||||
- name: "Build completion notification"
|
||||
command: "echo 'Build completed!'"
|
||||
|
||||
# =============================================================================
|
||||
# 清理配置
|
||||
# =============================================================================
|
||||
# 清理策略控制构建完成后的资源回收
|
||||
# WIP: 更多选项待设计
|
||||
|
||||
cleanup:
|
||||
# 清理策略:
|
||||
# - "auto": 自动清理(默认)
|
||||
# - "manual": 手动清理
|
||||
# - "on_failure": 仅失败时清理(WIP)
|
||||
policy: "manual"
|
||||
|
||||
# =============================================================================
|
||||
# 模板变量参考
|
||||
# =============================================================================
|
||||
# finalize.yml 支持以下模板变量
|
||||
#
|
||||
# 用户配置:
|
||||
# {{ variable.xxx }} - 用户在 Baker 中配置的值
|
||||
#
|
||||
# 密钥(来自 Vault):
|
||||
# {{ secret.xxx }} - 从 Vault 获取的密钥值
|
||||
# - 仅存在于内存中,不写入磁盘
|
||||
# - 日志中自动遮蔽
|
||||
# - 需要 PIPELINE_UNCOVER_SECRET 权限才能查看(需 2FA)
|
||||
#
|
||||
# 构建信息:
|
||||
# {{ build.status }} - 构建状态(动态,表示最后确认的状态)
|
||||
# {{ build.duration }} - 构建耗时
|
||||
# {{ build.stage }} - 当前/最后阶段
|
||||
#
|
||||
# 目标信息:
|
||||
# {{ target.xxx }} - 目标相关变量
|
||||
#
|
||||
# 注意:
|
||||
# - 变量可用性取决于执行阶段
|
||||
# - 在 prebake.ready 阶段,build.duration 可能不可用
|
||||
# - 使用不可用变量时行为 TBD
|
||||
|
||||
# =============================================================================
|
||||
# 与 prebake.yml 和 bake.sh 的关系
|
||||
# =============================================================================
|
||||
# 完整的 HBW 流水线包含三个阶段:
|
||||
#
|
||||
# 1. prebake (prebake.yml)
|
||||
# - 环境准备:Docker/Firecracker/Bare-metal
|
||||
# - 依赖安装:系统包、语言包
|
||||
# - 环境变量设置
|
||||
# - 缓存配置
|
||||
# - 输出:准备好的构建环境
|
||||
#
|
||||
# 2. bake (bake.sh)
|
||||
# - 构建执行:编译、测试、打包
|
||||
# - 通过 @decorator 控制执行流程
|
||||
# - 输出:构建产物
|
||||
#
|
||||
# 3. finalize (finalize.yml)
|
||||
# - 制品收集:收集构建产物
|
||||
# - 制品发布:推送到目标位置
|
||||
# - 通知发送:通知构建结果
|
||||
# - 资源清理:回收临时资源
|
||||
# - 输出:发布的制品、通知记录
|
||||
#
|
||||
# 数据流:
|
||||
# prebake → bake → finalize
|
||||
# 环境变量从 prebake 传递到 bake
|
||||
# 制品从 bake 传递到 finalize
|
||||
# Secret 在所有阶段可用(通过 {{ secret.xxx }})
|
||||
|
||||
# =============================================================================
|
||||
# 示例:完整流水线配置
|
||||
# =============================================================================
|
||||
# 项目 .workshop 目录通常包含:
|
||||
# - prebake.yml 或 prebake.yml.tmpl
|
||||
# - bake.sh 或 bake.sh.tmpl
|
||||
# - finalize.yml 或 finalize.yml.tmpl
|
||||
# - .env 环境变量(不应包含 secret)
|
||||
#
|
||||
# 模板文件(.tmpl 后缀)包含占位符,会在首次运行时生成实际配置
|
||||
# 系统会通过 LLM 或模板引擎填充这些占位符
|
||||
|
||||
# vim: set ft=yaml ts=2 sw=2 et:
|
||||
@@ -0,0 +1,243 @@
|
||||
# prebake.yaml.tmpl
|
||||
#
|
||||
# 术语说明:
|
||||
# 可选:该字段可以不给出
|
||||
# 默认为...:隐含“可选”
|
||||
# 留空:尚未定义,留作后续
|
||||
|
||||
# 版本号,用于格式兼容性检查
|
||||
# 到目前为止,"1.0"的版本号并不意味着schema就这么确定下来
|
||||
version: "1.0"
|
||||
|
||||
# 构建环境定义
|
||||
environment:
|
||||
# 构建机类型:docker | firecracker | custom | baremetal
|
||||
builder: "docker"
|
||||
|
||||
# 构建机配置,与builder一致的被启用。
|
||||
baremetal: {}
|
||||
|
||||
docker:
|
||||
# 镜像名,与dockerfile互斥
|
||||
image: "rust:1.70-slim"
|
||||
|
||||
# Dockerfile路径,相对于项目根目录,与image互斥
|
||||
dockerfile: "/Dockerfile"
|
||||
# Docker构建参数,可选
|
||||
build_args:
|
||||
RUST_VERSION: "1.70"
|
||||
|
||||
firecracker: {}
|
||||
# TODO: 定义firecracker结构
|
||||
|
||||
custom:
|
||||
# 自定义构建机名称
|
||||
name: "my-custom-builder"
|
||||
# 设置脚本,相对于项目根目录
|
||||
setup_script: "setup-builder.sh"
|
||||
# 清洁脚本,相对于项目根目录
|
||||
cleanup_script: "cleanup-builder.sh"
|
||||
|
||||
# 硬件资源要求,满足最小要求的构建机可以被选中
|
||||
resources:
|
||||
cpu: 2 # CPU核心数,默认为1
|
||||
memory: "2G" # 内存大小,也可以写作2048MB,默认为"1G"
|
||||
disk: "10G" # 磁盘空间,也可以写作10240MB,默认为"1G"
|
||||
architecture: "x86_64" # 架构要求,只能是"noarch"或{"x86_64"/"amd64", "arm64"/"aarch64", "riscv64", "loongarch64"/"loong64"}中的一个或几个
|
||||
# TODO: 重新设计GPU字段
|
||||
# gpu: false # 是否需要GPU,默认为false
|
||||
# gpu_type: "cuda" # GPU类型(如果需要),可以是"cuda", "rocm", "vulkan", "oneapi"中的一个或几个
|
||||
tags: # 自定义标签,只有满足标签的构建机可以被选中
|
||||
- "custom_tag"
|
||||
|
||||
# 网络配置
|
||||
network:
|
||||
enabled: true # 启用网络,对baremetal不适用,默认为true
|
||||
outbound: true # 允许出站连接,对baremetal不适用,默认为true
|
||||
dns_servers: # 指定构建机的DNS服务器,对baremetal不适用,custom应该自行处理DNS问题,可选
|
||||
- "8.8.8.8"
|
||||
- "1.1.1.1"
|
||||
proxies: # 代理配置,暂时留空,可选
|
||||
|
||||
# 构建环境初始化
|
||||
bootstrap:
|
||||
# 构建用户,默认为 vulcan(火神)
|
||||
# 设定为 "root" 表示忽略用户切换并禁用安全特性,需要 PIPELINE_PRIVILEGED 权限
|
||||
user: "vulcan"
|
||||
|
||||
# 工作空间
|
||||
workspace:
|
||||
# 工作目录路径
|
||||
path: "/home/vulcan/workspace"
|
||||
# 路径不可用时回退到 /workspace
|
||||
# 即使路径可用,也会在 /workspace 建立软链接
|
||||
fallback: true
|
||||
|
||||
# 自定义 sudoers 文件内容
|
||||
# 这会覆盖默认行为,可能绕过安全机制
|
||||
# 需要 PIPELINE_CUSTOM_SUDOERS 权限
|
||||
sudoers: "vulcan ALL=(ALL:ALL) NOPASSWD: ALL"
|
||||
|
||||
# 自定义 doas 文件内容
|
||||
# 这会覆盖默认行为,可能绕过安全机制
|
||||
# 与 sudoers 共享 PIPELINE_CUSTOM_SUDOERS 权限
|
||||
doas: "permit nopass vulcan as root"
|
||||
|
||||
# 自定义 bootstrap 脚本(与上述所有字段互斥)
|
||||
# 支持多种来源:
|
||||
# workspace:///.workshop/bootstrap.sh - 项目内文件
|
||||
# file:///bin/bootstrap.sh - 环境内文件
|
||||
# server://bootstrap.sh - 由服务器提供
|
||||
# https://example.com/bootstrap.sh - http/https网络下载,请确保构建环境可以连接到该URL
|
||||
# (content) - 直接给出内容,当文本开头无法匹配任何scheme时采用
|
||||
# 需要 PIPELINE_CUSTOM_BOOTSTRAP 权限
|
||||
custom: "server://bootstrap-alpine.sh"
|
||||
|
||||
|
||||
|
||||
# 依赖定义,安装依赖时按system-user顺序进行
|
||||
dependencies:
|
||||
# Repology 端点配置,用于将 UPM 包名解析为系统包名
|
||||
# 可选值:disabled, none, local, remote, server, default
|
||||
# - disabled: 完全禁用 Repology
|
||||
# - none: 不解析,直接使用原始包名
|
||||
# - local: 使用本地 Repology 服务器
|
||||
# - remote: 使用远程 Repology API
|
||||
# - server: 由服务器提供 Repology 服务
|
||||
# - default: 默认行为(通常等同于 local)
|
||||
config:
|
||||
repology_endpoint: "default"
|
||||
|
||||
# 系统包依赖,按包管理器枚举给出,只应该给出与环境契合的字段。
|
||||
# 根据security.drop-after的配置,在此阶段一般具有root或免密root权限
|
||||
# 不适用于裸机
|
||||
system:
|
||||
apt:
|
||||
packages: # 安装的软件包
|
||||
- "git"
|
||||
- "curl"
|
||||
- "build-essential"
|
||||
- "pkg-config"
|
||||
- "libssl-dev"
|
||||
repositories: # 该字段按serde Value原样传递给插件,config.rs不做解析
|
||||
- url: "ppa:custom/ppa" # 自定义PPA(Ubuntu)
|
||||
- url: "https://download.docker.com/linux/ubuntu" # DEB822
|
||||
key: "https://download.docker.com/linux/ubuntu/gpg"
|
||||
mirror: "https://mirrors.tuna.tsinghua.edu.cn" # 指定软件包镜像源,可选
|
||||
pacman:
|
||||
packages:
|
||||
- "git"
|
||||
- "curl"
|
||||
- "base-devel"
|
||||
- "pkg-config"
|
||||
- "openssl"
|
||||
repositories:
|
||||
- server: "https://mirrors.tuna.tsinghua.edu.cn/arch4edu/$arch" # 自定义仓库(Arch)
|
||||
name: "arch4edu"
|
||||
keypackage: "arch4edu-keyring"
|
||||
mirror: "https://mirrors.tuna.tsinghua.edu.cn" # 指定软件包镜像源,可选
|
||||
#dnf:
|
||||
# 略
|
||||
|
||||
# 用户依赖
|
||||
# 根据security.drop-after的配置,在此阶段一般不具有root权限
|
||||
# 例外:语言依赖会在system中产生隐式依赖
|
||||
user:
|
||||
# 以下字段按serde Value原样传递给插件,config.rs不做解析
|
||||
# rust:
|
||||
# type: "rustup" # 此时会向系统引入隐式的rustup依赖
|
||||
# toolchain: "nightly-x86_64-unknown-linux-gnu"
|
||||
# manifest: "Cargo.toml"
|
||||
# lock: "Cargo.lock"
|
||||
# python:
|
||||
# type: "uv"
|
||||
# python: "python3.14"
|
||||
# index: "http://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
|
||||
# directory: ".venv"
|
||||
# env: # 优先,高级选项
|
||||
# UV_PYTHON: "python3.14"
|
||||
# UV_INDEX: "http://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
|
||||
# UV_DIRECTORY: ".venv"
|
||||
# manifest: "requirements.txt" # 或pyproject.toml
|
||||
# nix: {}
|
||||
# # TBD: 没用过lol
|
||||
# # PR welcome
|
||||
custom: # 绝对的自定义,与上述全部配置互斥(custom独占)
|
||||
- command: "cargo fetch"
|
||||
working_dir: "/tmp"
|
||||
|
||||
# 环境变量配置
|
||||
envvars:
|
||||
# 构建环境变量,只在 prebake 阶段有效
|
||||
# 用于控制 prebake 阶段的行为(apt/cargo/rustc 等)
|
||||
prebake:
|
||||
RUST_BACKTRACE: "full" # Rust 调试
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: "true" # Cargo 配置
|
||||
NODE_ENV: "production" # Node 环境
|
||||
|
||||
# 运行时环境变量(会传递给 bake 阶段)
|
||||
# 这些变量会与项目中的 .env 文件合并后,作为最终环境
|
||||
bake:
|
||||
# 支持 secret 引用,格式: {{ secret.<key> }}
|
||||
DATABASE_URL: "postgresql://user:{{ secret.dbpassword }}@localhost/db"
|
||||
LOG_LEVEL: "info"
|
||||
|
||||
# 特殊变量:指定项目中 .env 文件的位置
|
||||
# 系统会读取该文件,并将其中的变量与上面定义的变量合并
|
||||
# 合并规则:prebake.yml 中定义的变量优先级更高,会覆盖 .env 中的同名变量
|
||||
# 此变量本身不会传递给 bake 阶段
|
||||
# .env不应该包含任何 secret !
|
||||
__ENV_FILE: ".env"
|
||||
|
||||
# 注意:
|
||||
# - secret 只存在于内存中,不会写入磁盘
|
||||
# - 日志中会自动遮蔽 secret 的值
|
||||
# - 如需临时查看 secret,可使用 PIPELINE_UNCOVER_SECRET 权限(需 2FA)
|
||||
|
||||
|
||||
# 缓存配置
|
||||
cache:
|
||||
# 缓存目录
|
||||
directory:
|
||||
- path: "/usr/local/cargo"
|
||||
strategy: "always" # 在"always","readonly","clean"和"never"中选择一个,可以被流水线启动时配置覆盖,默认为"always"
|
||||
mode: "zstd" # 在"gzip","zstd","none"和"dir"中选择一个
|
||||
|
||||
- path: "/root/.npm"
|
||||
strategy: "clean"
|
||||
mode: "none"
|
||||
|
||||
- path: "/app/target"
|
||||
strategy: "always"
|
||||
mode: "dir"
|
||||
|
||||
# 缓存策略
|
||||
strategy:
|
||||
ttl_days: 30 # 缓存生存时间
|
||||
max_size_gb: 20 # 最大缓存大小
|
||||
cleanup_policy: "lru" # 清理策略:lru, fifo, size
|
||||
|
||||
# 安全配置
|
||||
security:
|
||||
#
|
||||
drop-after: ""
|
||||
# 留空
|
||||
|
||||
# 钩子脚本
|
||||
hooks:
|
||||
# 依赖安装前
|
||||
early:
|
||||
- command: "echo 'Starting dependency installation'"
|
||||
name: ""
|
||||
|
||||
# 依赖安装后
|
||||
late:
|
||||
- command: "cargo fetch"
|
||||
working_dir: "/tmp"
|
||||
|
||||
# 元数据
|
||||
metadata:
|
||||
description: "Rust project build environment"
|
||||
maintainer: "team@example.com"
|
||||
|
||||
# vim: set ft=yaml ts=2 sw=2 et:
|
||||
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,24 @@
|
||||
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>,
|
||||
}
|
||||
|
||||
impl MergeParams for BakeParams {
|
||||
fn defaults() -> Self { Self { workspace: ParamVal::new("/workspace".into()) } }
|
||||
fn apply(&mut self, i: Self, w: Writer, ov: &mut Overridden, p: &str) {
|
||||
apply_field!(self.workspace, i.workspace, w, ov, p, "workspace");
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BakeParams { fn default() -> Self { Self::defaults() } }
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BakeSettings { pub workspace: String }
|
||||
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() } } }
|
||||
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(30), batch_watermark: ParamVal::new(5),
|
||||
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 } }
|
||||
@@ -0,0 +1,4 @@
|
||||
temp
|
||||
examples
|
||||
quicktest.sh
|
||||
_env.sh
|
||||
+1812
-1700
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
[package]
|
||||
name = "workshop-baker"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "workshop_baker"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "workshop-baker"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
integration-tests = []
|
||||
|
||||
[dependencies]
|
||||
workshop-engine = { path = "../workshop-engine" }
|
||||
anyhow = "1.0.100"
|
||||
bollard = { version = "0.20.0", features = ["buildkit", "chrono", "ssh"] }
|
||||
cgroups-rs = "0.5.0"
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
clap = { version = "4.5.53", features = ["derive", "env"] }
|
||||
config = "0.15.19"
|
||||
duration-str = "0.21.0"
|
||||
env_logger = "0.11.8"
|
||||
glob = "0.3.2"
|
||||
futures-util = "0.3.31"
|
||||
lazy_static = "1.5.0"
|
||||
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"
|
||||
semver = "1.0.27"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.148"
|
||||
serde_yaml = "0.9.34"
|
||||
shlex = "1.3.0"
|
||||
tar = "0.4.44"
|
||||
tempfile = "3.24.0"
|
||||
thiserror = "2.0.17"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
topological-sort = "0.2.2"
|
||||
async-trait = "0.1.87"
|
||||
uuid = { version = "1.19.0", features = ["v4"] }
|
||||
libc = "0.2"
|
||||
os_info = "3.14.0"
|
||||
privdrop = "0.5.6"
|
||||
which = "8.0.2"
|
||||
globset = "0.4.18"
|
||||
axum = "0.7"
|
||||
workshop-baker-params = { version = "0.1.0", path = "../workshop-baker-params" }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = "0.5"
|
||||
|
||||
[lints.rust]
|
||||
dead_code = "allow"
|
||||
unreachable_code = "allow"
|
||||
|
||||
[lints.clippy]
|
||||
inherent_to_string = "allow"
|
||||
non_canonical_partial_ord_impl = "allow"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
FROM alpine:latest
|
||||
RUN echo "Hello from Docker!" && \
|
||||
aps add --no-cache curl && \
|
||||
curl --version
|
||||
CMD ["sh", "-c", "echo 'Container started' && sleep 3600"]
|
||||
@@ -0,0 +1,240 @@
|
||||
# prebake.yaml.tmpl
|
||||
#
|
||||
# 术语说明:
|
||||
# 可选:该字段可以不给出
|
||||
# 默认为...:隐含“可选”
|
||||
# 留空:尚未定义,留作后续
|
||||
|
||||
# 版本号,用于格式兼容性检查
|
||||
version: "1.0"
|
||||
|
||||
# 构建环境定义
|
||||
environment:
|
||||
# 构建机类型:docker | firecracker | custom | baremetal
|
||||
builder: "docker"
|
||||
|
||||
# 构建机配置,与builder一致的被启用。
|
||||
baremetal: {}
|
||||
|
||||
docker:
|
||||
# 镜像名,与dockerfile互斥
|
||||
image: "rust:1.70-slim"
|
||||
|
||||
# Dockerfile路径,相对于项目根目录,与image互斥
|
||||
dockerfile: "/Dockerfile"
|
||||
# Docker构建参数,可选
|
||||
build_args:
|
||||
RUST_VERSION: "1.70"
|
||||
|
||||
firecracker: {}
|
||||
# TODO: 定义firecracker结构
|
||||
|
||||
custom:
|
||||
# 自定义构建机名称
|
||||
name: "my-custom-builder"
|
||||
# 设置脚本,相对于项目根目录
|
||||
setup_script: "setup-builder.sh"
|
||||
# 清洁脚本,相对于项目根目录
|
||||
cleanup_script: "cleanup-builder.sh"
|
||||
|
||||
# 硬件资源要求,满足最小要求的构建机可以被选中
|
||||
resources:
|
||||
cpu: 2 # CPU核心数,默认为1
|
||||
memory: "2G" # 内存大小,也可以写作2048MB,默认为"1G"
|
||||
disk: "10G" # 磁盘空间,也可以写作10240MB,默认为"1G"
|
||||
architecture: "x86_64" # 架构要求,只能是"noarch"或{"x86_64"/"amd64", "arm64"/"aarch64", "riscv64", "loongarch64"/"loong64"}中的一个或几个
|
||||
# TODO: 重新设计GPU字段
|
||||
# gpu: false # 是否需要GPU,默认为false
|
||||
# gpu_type: "cuda" # GPU类型(如果需要),可以是"cuda", "rocm", "vulkan", "oneapi"中的一个或几个
|
||||
tags: # 自定义标签,只有满足标签的构建机可以被选中
|
||||
- "custom_tag"
|
||||
|
||||
# 网络配置
|
||||
network:
|
||||
enabled: true # 启用网络,对baremetal不适用,默认为true
|
||||
outbound: true # 允许出站连接,对baremetal不适用,默认为true
|
||||
dns_servers: # 指定构建机的DNS服务器,对baremetal不适用,custom应该自行处理DNS问题,可选
|
||||
- "8.8.8.8"
|
||||
- "1.1.1.1"
|
||||
proxies: # 代理配置,暂时留空,可选
|
||||
|
||||
# 构建环境初始化
|
||||
bootstrap:
|
||||
# 构建用户,默认为 vulcan(火神)
|
||||
# 设定为 "root" 表示忽略用户切换并禁用安全特性,需要 PIPELINE_PRIVILEGED 权限
|
||||
user: "vulcan"
|
||||
|
||||
# 工作空间
|
||||
workspace:
|
||||
# 工作目录路径
|
||||
path: "/home/vulcan/workspace"
|
||||
# 路径不可用时回退到 /workspace
|
||||
# 即使路径可用,也会在 /workspace 建立软链接
|
||||
fallback: true
|
||||
|
||||
# 自定义 sudoers 文件内容
|
||||
# 这会覆盖默认行为,可能绕过安全机制
|
||||
# 需要 PIPELINE_CUSTOM_SUDOERS 权限
|
||||
sudoers: "vulcan ALL=(ALL:ALL) NOPASSWD: ALL"
|
||||
|
||||
# 自定义 doas 文件内容
|
||||
# 这会覆盖默认行为,可能绕过安全机制
|
||||
# 与 sudoers 共享 PIPELINE_CUSTOM_SUDOERS 权限
|
||||
doas: "permit nopass vulcan as root"
|
||||
|
||||
# 自定义 bootstrap 脚本(与上述所有字段互斥)
|
||||
# 支持多种来源:
|
||||
# workspace:///.workshop/bootstrap.sh - 项目内文件
|
||||
# file:///bin/bootstrap.sh - 环境内文件
|
||||
# server://bootstrap.sh - 由服务器提供
|
||||
# https://example.com/bootstrap.sh - http/https网络下载,请确保构建环境可以连接到该URL
|
||||
# (content) - 直接给出内容,当文本开头无法匹配任何scheme时采用
|
||||
# 需要 PIPELINE_CUSTOM_BOOTSTRAP 权限
|
||||
custom: "server://bootstrap-alpine.sh"
|
||||
|
||||
|
||||
|
||||
# 依赖定义,安装依赖时按system-user顺序进行
|
||||
dependencies:
|
||||
# Repology 端点配置,用于将 UPM 包名解析为系统包名
|
||||
# 可选值:disabled, none, local, remote, server, default
|
||||
# - disabled: 完全禁用 Repology
|
||||
# - none: 不解析,直接使用原始包名
|
||||
# - local: 使用本地 Repology 服务器
|
||||
# - remote: 使用远程 Repology API
|
||||
# - server: 由服务器提供 Repology 服务
|
||||
# - default: 默认行为(通常等同于 local)
|
||||
config:
|
||||
repology_endpoint: "default"
|
||||
|
||||
# 系统包依赖,按包管理器枚举给出,只应该给出与环境契合的字段。
|
||||
# 根据security.drop-after的配置,在此阶段一般具有root或免密root权限
|
||||
# 不适用于裸机
|
||||
system:
|
||||
apt:
|
||||
packages: # 安装的软件包
|
||||
- "git"
|
||||
- "curl"
|
||||
- "build-essential"
|
||||
- "pkg-config"
|
||||
- "libssl-dev"
|
||||
repositories: # 该字段按serde Value原样传递给插件,config.rs不做解析
|
||||
- url: "ppa:custom/ppa" # 自定义PPA(Ubuntu)
|
||||
- url: "https://download.docker.com/linux/ubuntu" # DEB822
|
||||
key: "https://download.docker.com/linux/ubuntu/gpg"
|
||||
mirror: "https://mirrors.tuna.tsinghua.edu.cn" # 指定软件包镜像源,可选
|
||||
pacman:
|
||||
packages:
|
||||
- "git"
|
||||
- "curl"
|
||||
- "base-devel"
|
||||
- "pkg-config"
|
||||
- "openssl"
|
||||
repositories:
|
||||
- server: "https://mirrors.tuna.tsinghua.edu.cn/arch4edu/$arch" # 自定义仓库(Arch)
|
||||
name: "arch4edu"
|
||||
keypackage: "arch4edu-keyring"
|
||||
mirror: "https://mirrors.tuna.tsinghua.edu.cn" # 指定软件包镜像源,可选
|
||||
#dnf:
|
||||
# 略
|
||||
|
||||
# 用户依赖
|
||||
# 根据security.drop-after的配置,在此阶段一般不具有root权限
|
||||
# 例外:语言依赖会在system中产生隐式依赖
|
||||
user:
|
||||
# 以下字段按serde Value原样传递给插件,config.rs不做解析
|
||||
# rust:
|
||||
# type: "rustup" # 此时会向系统引入隐式的rustup依赖
|
||||
# toolchain: "nightly-x86_64-unknown-linux-gnu"
|
||||
# manifest: "Cargo.toml"
|
||||
# lock: "Cargo.lock"
|
||||
# python:
|
||||
# type: "uv"
|
||||
# python: "python3.14"
|
||||
# index: "http://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
|
||||
# directory: ".venv"
|
||||
# env: # 优先,高级选项
|
||||
# UV_PYTHON: "python3.14"
|
||||
# UV_INDEX: "http://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
|
||||
# UV_DIRECTORY: ".venv"
|
||||
# manifest: "requirements.txt" # 或pyproject.toml
|
||||
# nix: {}
|
||||
# # TBD: 没用过lol
|
||||
# # PR welcome
|
||||
custom: # 绝对的自定义,与上述全部配置互斥(custom独占)
|
||||
- command: "cargo fetch"
|
||||
working_dir: "/tmp"
|
||||
|
||||
# 环境变量配置
|
||||
envvars:
|
||||
# 构建环境变量,只在 prebake 阶段有效
|
||||
# 用于控制 prebake 阶段的行为(apt/cargo/rustc 等)
|
||||
prebake:
|
||||
RUST_BACKTRACE: "full" # Rust 调试
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: "true" # Cargo 配置
|
||||
NODE_ENV: "production" # Node 环境
|
||||
|
||||
# 运行时环境变量(会传递给 bake 阶段)
|
||||
# 这些变量会与项目中的 .env 文件合并后,作为最终环境
|
||||
bake:
|
||||
# 支持 secret 引用,格式: {{ secret.<key> }}
|
||||
DATABASE_URL: "postgresql://user:{{ secret.dbpassword }}@localhost/db"
|
||||
LOG_LEVEL: "info"
|
||||
|
||||
# 特殊变量:指定项目中 .env 文件的位置
|
||||
# 系统会读取该文件,并将其中的变量与上面定义的变量合并
|
||||
# 合并规则:prebake.yml 中定义的变量优先级更高,会覆盖 .env 中的同名变量
|
||||
# 此变量本身不会传递给 bake 阶段
|
||||
# .env不应该包含任何 secret !
|
||||
__ENV_FILE: ".env"
|
||||
|
||||
# 注意:
|
||||
# - secret 只存在于内存中,不会写入磁盘
|
||||
# - 日志中会自动遮蔽 secret 的值
|
||||
# - 如需临时查看 secret,可使用 PIPELINE_UNCOVER_SECRET 权限(需 2FA)
|
||||
|
||||
|
||||
# 缓存配置
|
||||
cache:
|
||||
# 缓存目录
|
||||
directory:
|
||||
- path: "/usr/local/cargo"
|
||||
strategy: "always" # 在"always","readonly","clean"和"never"中选择一个,可以被流水线启动时配置覆盖,默认为"always"
|
||||
mode: "zstd" # 在"gzip","zstd","none"和"dir"中选择一个
|
||||
|
||||
- path: "/root/.npm"
|
||||
strategy: "clean"
|
||||
mode: "none"
|
||||
|
||||
- path: "/app/target"
|
||||
strategy: "always"
|
||||
mode: "dir"
|
||||
|
||||
# 缓存策略
|
||||
strategy:
|
||||
ttl_days: 30 # 缓存生存时间
|
||||
max_size_gb: 20 # 最大缓存大小
|
||||
cleanup_policy: "lru" # 清理策略:lru, fifo, size
|
||||
|
||||
# 安全配置
|
||||
security:
|
||||
#
|
||||
drop-after: ""
|
||||
# 留空
|
||||
|
||||
# 钩子脚本
|
||||
hooks:
|
||||
# 依赖安装前
|
||||
early:
|
||||
- command: "echo 'Starting dependency installation'"
|
||||
name: ""
|
||||
|
||||
# 依赖安装后
|
||||
late:
|
||||
- command: "cargo fetch"
|
||||
working_dir: "/tmp"
|
||||
|
||||
# 元数据
|
||||
metadata:
|
||||
description: "Rust project build environment"
|
||||
maintainer: "team@example.com"
|
||||
@@ -0,0 +1,11 @@
|
||||
[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
|
||||
@@ -0,0 +1,57 @@
|
||||
# workshop-baker/src
|
||||
|
||||
**Generated:** 2026-05-19
|
||||
**Commit:** 28abc5d
|
||||
|
||||
CLI tool and runtime for executing CI/CD pipelines. Orchestrates prebake → bake → finalize stages with notification dispatch.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
src/
|
||||
├── lib.rs # Module declarations + Cli struct + Commands enum
|
||||
├── main.rs # tokio::main entry point
|
||||
├── bare.rs # Bare mode (run individual stages)
|
||||
├── error.rs # CliError (wraps stage errors)
|
||||
├── bake.rs # Bake orchestration (script → execution)
|
||||
├── bake/ # Script parsing, decorator DSL, scheduling
|
||||
├── prebake.rs # Prebake orchestration (env setup)
|
||||
├── prebake/ # Config, stages, security, env providers
|
||||
├── finalize.rs # Finalize orchestration (post-build)
|
||||
├── finalize/ # Config, plugins, hooks, templates
|
||||
├── notify.rs # Notification system (batching, retry)
|
||||
├── notify/ # Types, templates, interactive, queue
|
||||
├── daemon/ # Empty (Daemon command = unimplemented!)
|
||||
├── monitor/ # Resource usage monitoring (cgroups)
|
||||
├── types.rs # Module re-exports
|
||||
├── types/ # buildstatus, resource types
|
||||
├── utils.rs # Module root (fetch submodule)
|
||||
└── utils/ # fetch.rs
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location |
|
||||
|------|----------|
|
||||
| CLI args | `lib.rs:21` - `Cli` struct |
|
||||
| Entry point | `main.rs:91` - `#[tokio::main]` |
|
||||
| Pipeline orchestration | `main.rs:68-74` - prebake→bake→finalize chain |
|
||||
| Bare mode (single stage) | `bare.rs:14` - `run_bare()` |
|
||||
| Script parsing | `bake/parser.rs:56` - `parse_script()` |
|
||||
| Decorator DSL | `bake/decorator.rs` - `Decorator` enum |
|
||||
| Prebake stages | `prebake/stage/` - Bootstrap→DepsSystem→DepsUser→Hooks→Ready |
|
||||
| Plugin system | `finalize/plugin.rs` + `finalize/plugin/` |
|
||||
| Notification dispatch | `notify.rs:66` - `notify()` async loop |
|
||||
| Notification types | `notify/types.rs` - `NotificationRule`, `ParsedTrigger` |
|
||||
|
||||
## CONVENTIONS
|
||||
- **ExecutionContext**: Passed through all stages, carries task_id, username, env_vars, timeout
|
||||
- **PrebakeStage**: Ordered enum Bootstrap→EarlyHook→DepsSystem→DepsUser→LateHook→Ready
|
||||
- **Decorator**: `# @name(args)` comments above shell function declarations
|
||||
- **Notification**: Plugin-based dispatch with batching, exponential backoff, priority groups
|
||||
- **Engine**: Delegated to `workshop-engine` crate (formerly in-tree)
|
||||
- **Dual-mode**: CLI commands (standalone) vs Daemon mode (unimplemented)
|
||||
|
||||
## ANTI-PATTERNS
|
||||
1. **Empty daemon/** directory — Daemon command is `unimplemented!()`
|
||||
2. **Dead code allowed** — `lints.rust.dead_code = "allow"` in Cargo.toml
|
||||
3. **Engine was extracted** — workshop-engine is its own crate now, src/engine/ removed
|
||||
4. **Python test infra exists but files removed** — pytest.ini remains, test dir is empty
|
||||
@@ -0,0 +1,120 @@
|
||||
use crate::bake::constant::{DEFAULT_WORKSPACE, TRIVIAL_SCRIPT_NAME};
|
||||
use crate::bake::error::BakeError;
|
||||
use crate::bake::parser::Function;
|
||||
use crate::cli::Cli;
|
||||
use crate::prebake;
|
||||
use crate::prebake::PrebakeConfig;
|
||||
use crate::prebake::security::get_drop_after;
|
||||
use crate::prebake::stage::PrebakeStage;
|
||||
use std::path::{Path, PathBuf};
|
||||
use workshop_engine::{Engine, EventSender, ExecutionContext};
|
||||
|
||||
mod builder;
|
||||
pub mod constant;
|
||||
mod decorator;
|
||||
pub mod error;
|
||||
pub mod parser;
|
||||
mod schedule;
|
||||
|
||||
pub async fn bake(
|
||||
script_path: &Path,
|
||||
bake_base_path: &Path,
|
||||
prebake_path: &Path,
|
||||
_cli: &Cli,
|
||||
ctx: &mut ExecutionContext,
|
||||
event_tx: EventSender,
|
||||
) -> Result<(), BakeError> {
|
||||
let script_content = std::fs::read_to_string(script_path)?;
|
||||
let prebake_content = std::fs::read_to_string(prebake_path)?;
|
||||
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);
|
||||
let functions = parser::parse_script(&script_content)?;
|
||||
|
||||
let has_pipeline = functions.pipeline_functions.iter().any(|f| {
|
||||
f.decorators
|
||||
.iter()
|
||||
.any(|d| matches!(d, decorator::Decorator::Pipeline))
|
||||
});
|
||||
let trivial = false;
|
||||
let use_template = true;
|
||||
|
||||
let engine = Engine::new();
|
||||
if let Some(env) = &prebake.envvars
|
||||
&& let Some(prebake_env) = &env.bake
|
||||
{
|
||||
ctx.env_vars.extend(prebake_env.clone());
|
||||
}
|
||||
|
||||
if !has_pipeline || trivial {
|
||||
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;
|
||||
result?;
|
||||
} else {
|
||||
let sorted_functions = schedule::sort_function(functions.pipeline_functions)?;
|
||||
let remaining = functions.remaining_code;
|
||||
for func in sorted_functions {
|
||||
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;
|
||||
result?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_workspace(prebake_config: &PrebakeConfig) -> PathBuf {
|
||||
prebake_config
|
||||
.bootstrap
|
||||
.as_ref()
|
||||
.and_then(|b| b.workspace.as_ref())
|
||||
.map(|ws| {
|
||||
if ws.fallback {
|
||||
PathBuf::from(DEFAULT_WORKSPACE)
|
||||
} else {
|
||||
PathBuf::from(ws.path.clone())
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_WORKSPACE))
|
||||
}
|
||||
|
||||
fn privileged(prebake_config: &PrebakeConfig) -> bool {
|
||||
if let Some(bootstrap) = &prebake_config.bootstrap
|
||||
&& 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 {
|
||||
|
||||
// }
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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
|
||||
├── error.rs # BakeError enum
|
||||
└── constant.rs # DEFAULT_WORKSPACE, TRIVIAL_SCRIPT_NAME
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Task | Location |
|
||||
|------|----------|
|
||||
Parse script | `parser.rs:56` - `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
|
||||
@@ -0,0 +1,158 @@
|
||||
use crate::bake::constant::{PIPELINE_SKIP_ERRORCODE, TRIVIAL_SCRIPT_NAME};
|
||||
use crate::bake::decorator::Decorator;
|
||||
use crate::bake::error::BakeError;
|
||||
use crate::bake::parser::Function;
|
||||
use minijinja::{Environment, context};
|
||||
use std::fs::Permissions;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
/// Template for use_template=false, which just executes the main body without any wrapping.
|
||||
const TRIVIAL_TEMPLATE: &str = "{{ main }}";
|
||||
|
||||
/// Renders a `Function` into an executable shell script using a minijinja template.
|
||||
/// If `use_template` is true, wraps the body in `bake_base`; otherwise uses a trivial template.
|
||||
pub fn build_script(
|
||||
function: &Function,
|
||||
bake_base: &Path,
|
||||
target_dir: &Path,
|
||||
temp_path: Option<PathBuf>,
|
||||
use_template: bool,
|
||||
) -> 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())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let temp_path = temp_path.unwrap_or_else(|| PathBuf::from("/tmp"));
|
||||
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(),
|
||||
})?
|
||||
} else {
|
||||
TRIVIAL_TEMPLATE.to_string()
|
||||
};
|
||||
|
||||
let condition_code = match condition {
|
||||
Some(content) => {
|
||||
format!(
|
||||
"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());
|
||||
}
|
||||
|
||||
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})?
|
||||
};
|
||||
|
||||
let mut temp_file =
|
||||
NamedTempFile::new_in(&temp_path).map_err(|e| BakeError::ScriptGenerationFailed {
|
||||
script: temp_path.display().to_string(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
temp_file
|
||||
.as_file_mut()
|
||||
.set_permissions(Permissions::from_mode(0o700))
|
||||
.map_err(|e| BakeError::ScriptGenerationFailed {
|
||||
script: temp_path.display().to_string(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
std::fs::write(&temp_file, rendered).map_err(|e| BakeError::ScriptGenerationFailed {
|
||||
script: temp_path.display().to_string(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
temp_file
|
||||
.persist(&target)
|
||||
.map_err(|e| BakeError::ScriptGenerationFailed {
|
||||
script: target.display().to_string(),
|
||||
reason: e.error.to_string(),
|
||||
})?;
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_script_with_template() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let bake_base = temp_dir.path().join("bake_base.sh");
|
||||
|
||||
std::fs::write(&bake_base, "{{ main }}\n").unwrap();
|
||||
|
||||
let function = Function {
|
||||
name: "test".to_string(),
|
||||
decorators: vec![],
|
||||
body: "echo 'hello world'".to_string(),
|
||||
};
|
||||
let result = build_script(&function, &bake_base, temp_dir.path(), None, true);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let script_path = result.unwrap();
|
||||
let content = std::fs::read_to_string(&script_path).unwrap();
|
||||
assert!(content.contains("echo 'hello world'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_script_trivial_mode() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let function = Function {
|
||||
name: "trivial".to_string(),
|
||||
decorators: vec![],
|
||||
body: "echo 'direct output'".to_string(),
|
||||
};
|
||||
|
||||
let result = build_script(
|
||||
&function,
|
||||
Path::new("/nonexistent"),
|
||||
temp_dir.path(),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let script_path = result.unwrap();
|
||||
let content = std::fs::read_to_string(&script_path).unwrap();
|
||||
assert_eq!(content.trim(), "echo 'direct output'");
|
||||
}
|
||||
}
|
||||
@@ -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: u8 = 233;
|
||||
@@ -0,0 +1,856 @@
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use std::fmt::Display;
|
||||
use std::fmt::Formatter;
|
||||
|
||||
use crate::bake::error::BakeError;
|
||||
|
||||
lazy_static! {
|
||||
// "# @decorator" or "# @decorator(parameters)"
|
||||
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 {
|
||||
/// 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>),
|
||||
/// Run concurrently with other parallel functions.
|
||||
Parallel,
|
||||
/// Run as a background daemon process.
|
||||
Daemon,
|
||||
/// Health check endpoint for daemon verification.
|
||||
Health(String),
|
||||
}
|
||||
|
||||
impl Display for Decorator {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Decorator::Unknown => write!(f, "@<unknown>"),
|
||||
Decorator::Pipeline => write!(f, "@pipeline"),
|
||||
Decorator::If(cond) => write!(f, "@if({})", cond),
|
||||
Decorator::Fallible => write!(f, "@fallible"),
|
||||
Decorator::Loop(count) => write!(f, "@loop({})", count),
|
||||
Decorator::After(func) => write!(f, "@after({})", func),
|
||||
Decorator::Timeout(timeout) => write!(f, "@timeout({})", timeout),
|
||||
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::Daemon => write!(f, "@daemon"),
|
||||
Decorator::Health(check) => write!(f, "@health({})", check),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
None => return Ok(None), // Actually not a decorator
|
||||
};
|
||||
|
||||
let name = caps.get(1).unwrap().as_str();
|
||||
let has_arg = caps.get(2).is_some();
|
||||
let args = caps.get(3).map(|m| m.as_str()).unwrap_or("");
|
||||
|
||||
let decorator_noarg = |d: Decorator| -> Result<Option<Decorator>, BakeError> {
|
||||
if has_arg {
|
||||
Err(BakeError::DecoratorParseError {
|
||||
decorator: name.to_string(),
|
||||
line_num,
|
||||
reason: UNEXPECTED_ARGUMENT.to_string(),
|
||||
})
|
||||
} else {
|
||||
Ok(Some(d))
|
||||
}
|
||||
};
|
||||
|
||||
let required_arg = || -> Result<&str, BakeError> {
|
||||
if !has_arg || args.is_empty() {
|
||||
Err(BakeError::DecoratorParseError {
|
||||
decorator: name.to_string(),
|
||||
line_num,
|
||||
reason: MISSING_ARGUMENT.to_string(),
|
||||
})
|
||||
} else {
|
||||
Ok(args)
|
||||
}
|
||||
};
|
||||
|
||||
match name {
|
||||
"pipeline" => decorator_noarg(Decorator::Pipeline),
|
||||
"if" => required_arg().map(|a| Some(Decorator::If(a.to_string()))),
|
||||
"fallible" => decorator_noarg(Decorator::Fallible),
|
||||
"loop" => required_arg().and_then(|a| {
|
||||
let count = a.parse().map_err(|_e| BakeError::DecoratorParseError {
|
||||
decorator: "loop".to_string(),
|
||||
line_num,
|
||||
reason: format!("invalid loop count: {}", a),
|
||||
})?;
|
||||
Ok(Some(Decorator::Loop(count)))
|
||||
}),
|
||||
"after" => required_arg().map(|a| Some(Decorator::After(a.to_string()))),
|
||||
"timeout" => required_arg().and_then(|a| {
|
||||
let timeout = a.parse().map_err(|_e| BakeError::DecoratorParseError {
|
||||
decorator: "timeout".to_string(),
|
||||
line_num,
|
||||
reason: format!("invalid timeout: {}", a),
|
||||
})?;
|
||||
Ok(Some(Decorator::Timeout(timeout)))
|
||||
}),
|
||||
"retry" => required_arg().and_then(|a| {
|
||||
let parts: Vec<&str> = a.split(',').collect();
|
||||
if parts.len() != 2 {
|
||||
Err(BakeError::DecoratorParseError {
|
||||
decorator: "retry".to_string(),
|
||||
line_num,
|
||||
reason: format!("invalid retry args: {}", a),
|
||||
})
|
||||
} else {
|
||||
let count =
|
||||
parts[0]
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_e| BakeError::DecoratorParseError {
|
||||
decorator: "retry".to_string(),
|
||||
line_num,
|
||||
reason: format!("invalid retry count: {}", parts[0]),
|
||||
})?;
|
||||
let delay_str = parts[1].trim();
|
||||
if delay_str.is_empty() {
|
||||
Err(BakeError::DecoratorParseError {
|
||||
decorator: "retry".to_string(),
|
||||
line_num,
|
||||
reason: "no delay specified".to_string(),
|
||||
})
|
||||
} else {
|
||||
let delay = delay_str
|
||||
.parse()
|
||||
.map_err(|_e| BakeError::DecoratorParseError {
|
||||
decorator: "retry".to_string(),
|
||||
line_num,
|
||||
reason: format!("invalid retry delay: {}", parts[1]),
|
||||
})?;
|
||||
Ok(Some(Decorator::Retry(count, delay)))
|
||||
}
|
||||
}
|
||||
}),
|
||||
"export" => decorator_noarg(Decorator::Export),
|
||||
"parallel" => decorator_noarg(Decorator::Parallel),
|
||||
"daemon" => decorator_noarg(Decorator::Daemon),
|
||||
"health" => required_arg().map(|a| Some(Decorator::Health(a.to_string()))),
|
||||
"pipe" => required_arg().and_then(|a| {
|
||||
let functions: Vec<String> = a
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if functions.is_empty() {
|
||||
Err(BakeError::DecoratorParseError {
|
||||
decorator: "pipe".to_string(),
|
||||
line_num,
|
||||
reason: "no functions specified".to_string(),
|
||||
})
|
||||
} else {
|
||||
Ok(Some(Decorator::Pipe(functions)))
|
||||
}
|
||||
}),
|
||||
d => Err(BakeError::DecoratorParseError {
|
||||
decorator: d.to_string(),
|
||||
line_num,
|
||||
reason: UNKNOWN_DECORATOR.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// =====================================================================
|
||||
// Tests for Decorator Display implementation
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn test_decorator_display_pipeline() {
|
||||
assert_eq!(format!("{}", Decorator::Pipeline), "@pipeline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorator_display_if() {
|
||||
assert_eq!(
|
||||
format!("{}", Decorator::If("cond".to_string())),
|
||||
"@if(cond)"
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{}", Decorator::If("x > 0".to_string())),
|
||||
"@if(x > 0)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorator_display_fallible() {
|
||||
assert_eq!(format!("{}", Decorator::Fallible), "@fallible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorator_display_loop() {
|
||||
assert_eq!(format!("{}", Decorator::Loop(5)), "@loop(5)");
|
||||
assert_eq!(format!("{}", Decorator::Loop(0)), "@loop(0)");
|
||||
assert_eq!(
|
||||
format!("{}", Decorator::Loop(usize::MAX)),
|
||||
format!("@loop({})", usize::MAX)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorator_display_after() {
|
||||
assert_eq!(
|
||||
format!("{}", Decorator::After("func1".to_string())),
|
||||
"@after(func1)"
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{}", Decorator::After("build_step_1".to_string())),
|
||||
"@after(build_step_1)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorator_display_timeout() {
|
||||
assert_eq!(format!("{}", Decorator::Timeout(30)), "@timeout(30)");
|
||||
assert_eq!(format!("{}", Decorator::Timeout(0)), "@timeout(0)");
|
||||
assert_eq!(
|
||||
format!("{}", Decorator::Timeout(u32::MAX)),
|
||||
format!("@timeout({})", u32::MAX)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorator_display_retry() {
|
||||
assert_eq!(format!("{}", Decorator::Retry(3, 100)), "@retry(3, 100)");
|
||||
assert_eq!(format!("{}", Decorator::Retry(0, 0)), "@retry(0, 0)");
|
||||
assert_eq!(
|
||||
format!("{}", Decorator::Retry(10, 5000)),
|
||||
"@retry(10, 5000)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorator_display_export() {
|
||||
assert_eq!(format!("{}", Decorator::Export), "@export");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorator_display_pipe() {
|
||||
assert_eq!(format!("{}", Decorator::Pipe(vec![])), "@pipe()");
|
||||
assert_eq!(
|
||||
format!("{}", Decorator::Pipe(vec!["f1".to_string()])),
|
||||
"@pipe(f1)"
|
||||
);
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{}",
|
||||
Decorator::Pipe(vec!["f1".to_string(), "f2".to_string()])
|
||||
),
|
||||
"@pipe(f1, f2)"
|
||||
);
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{}",
|
||||
Decorator::Pipe(vec!["a".to_string(), "b".to_string(), "c".to_string()])
|
||||
),
|
||||
"@pipe(a, b, c)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorator_display_parallel() {
|
||||
assert_eq!(format!("{}", Decorator::Parallel), "@parallel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorator_display_daemon() {
|
||||
assert_eq!(format!("{}", Decorator::Daemon), "@daemon");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorator_display_health() {
|
||||
assert_eq!(
|
||||
format!("{}", Decorator::Health("http://localhost:8080".to_string())),
|
||||
"@health(http://localhost:8080)"
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{}", Decorator::Health("/health".to_string())),
|
||||
"@health(/health)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorator_display_unknown() {
|
||||
assert_eq!(format!("{}", Decorator::Unknown), "@<unknown>");
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests for parse_decorator - Valid no-argument decorators
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_pipeline() {
|
||||
let result = parse_decorator("# @pipeline", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Pipeline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_fallible() {
|
||||
let result = parse_decorator("# @fallible", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Fallible));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_export() {
|
||||
let result = parse_decorator("# @export", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Export));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_parallel() {
|
||||
let result = parse_decorator("# @parallel", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Parallel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_daemon() {
|
||||
let result = parse_decorator("# @daemon", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Daemon));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests for parse_decorator - Valid single-argument decorators
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_if() {
|
||||
let result = parse_decorator("# @if(condition)", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::If("condition".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_if_with_complex_condition() {
|
||||
let result = parse_decorator("# @if(env.STATUS == \"ready\")", 1).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(Decorator::If("env.STATUS == \"ready\"".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_after() {
|
||||
let result = parse_decorator("# @after(build_step)", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::After("build_step".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_after_with_underscore() {
|
||||
let result = parse_decorator("# @after(build_step_1)", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::After("build_step_1".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_loop() {
|
||||
let result = parse_decorator("# @loop(5)", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Loop(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_loop_zero() {
|
||||
let result = parse_decorator("# @loop(0)", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Loop(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_health() {
|
||||
let result = parse_decorator("# @health(/health)", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Health("/health".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_health_url() {
|
||||
let result = parse_decorator("# @health(http://localhost:8080/health)", 1).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(Decorator::Health(
|
||||
"http://localhost:8080/health".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_timeout() {
|
||||
let result = parse_decorator("# @timeout(30)", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Timeout(30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_timeout_zero() {
|
||||
let result = parse_decorator("# @timeout(0)", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Timeout(0)));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests for parse_decorator - Valid multi-argument decorators
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_retry() {
|
||||
let result = parse_decorator("# @retry(3, 100)", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Retry(3, 100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_retry_with_spaces() {
|
||||
let result = parse_decorator("# @retry(3,100)", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Retry(3, 100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_retry_with_many_spaces() {
|
||||
let result = parse_decorator("# @retry( 3 , 100 )", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Retry(3, 100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_retry_zero_count() {
|
||||
let result = parse_decorator("# @retry(0, 0)", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Retry(0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_pipe() {
|
||||
let result = parse_decorator("# @pipe(func1)", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Pipe(vec!["func1".to_string()])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_pipe_multiple() {
|
||||
let result = parse_decorator("# @pipe(func1, func2, func3)", 1).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(Decorator::Pipe(vec![
|
||||
"func1".to_string(),
|
||||
"func2".to_string(),
|
||||
"func3".to_string()
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_pipe_with_spaces() {
|
||||
let result = parse_decorator("# @pipe( func1 , func2 )", 1).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(Decorator::Pipe(vec![
|
||||
"func1".to_string(),
|
||||
"func2".to_string()
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests for parse_decorator - Whitespace handling
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_with_leading_whitespace() {
|
||||
let result = parse_decorator(" # @pipeline", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Pipeline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_with_extra_spaces() {
|
||||
let result = parse_decorator("# @pipeline", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Pipeline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_with_tab() {
|
||||
let result = parse_decorator("#\t@pipeline", 1).unwrap();
|
||||
assert_eq!(result, Some(Decorator::Pipeline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_no_space_between_hash_and_at() {
|
||||
// Regex requires at least one whitespace between # and @
|
||||
// So "#@pipeline" is NOT a valid decorator
|
||||
let result = parse_decorator("#@pipeline", 1).unwrap();
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests for parse_decorator - Non-decorator lines
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_no_hash() {
|
||||
let result = parse_decorator("@pipeline", 1).unwrap();
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_empty_line() {
|
||||
let result = parse_decorator("", 1).unwrap();
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_only_whitespace() {
|
||||
let result = parse_decorator(" ", 1).unwrap();
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_regular_comment() {
|
||||
let result = parse_decorator("# This is a regular comment", 1).unwrap();
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_code_line() {
|
||||
let result = parse_decorator("echo 'Hello World'", 1).unwrap();
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests for parse_decorator - Invalid: decorators with wrong arguments
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_pipeline_with_arg() {
|
||||
let result = parse_decorator("# @pipeline(something)", 1);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, BakeError::DecoratorParseError { .. }));
|
||||
if let BakeError::DecoratorParseError {
|
||||
decorator, reason, ..
|
||||
} = err
|
||||
{
|
||||
assert_eq!(decorator, "pipeline");
|
||||
assert!(reason.contains("does not accept arguments"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_fallible_with_arg() {
|
||||
let result = parse_decorator("# @fallible(true)", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_export_with_arg() {
|
||||
let result = parse_decorator("# @export(mode)", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_parallel_with_arg() {
|
||||
let result = parse_decorator("# @parallel(true)", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_daemon_with_arg() {
|
||||
let result = parse_decorator("# @daemon(stop)", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests for parse_decorator - Invalid: decorators missing required arguments
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_if_no_arg() {
|
||||
let result = parse_decorator("# @if", 1);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, BakeError::DecoratorParseError { .. }));
|
||||
if let BakeError::DecoratorParseError {
|
||||
decorator, reason, ..
|
||||
} = err
|
||||
{
|
||||
assert_eq!(decorator, "if");
|
||||
assert!(reason.contains("requires an argument"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_if_empty_parens() {
|
||||
let result = parse_decorator("# @if()", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_after_no_arg() {
|
||||
let result = parse_decorator("# @after", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_loop_no_arg() {
|
||||
let result = parse_decorator("# @loop", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_loop_empty_parens() {
|
||||
let result = parse_decorator("# @loop()", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_timeout_no_arg() {
|
||||
let result = parse_decorator("# @timeout", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_retry_no_arg() {
|
||||
let result = parse_decorator("# @retry", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_retry_only_count() {
|
||||
let result = parse_decorator("# @retry(3)", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_retry_only_comma() {
|
||||
let result = parse_decorator("# @retry(,)", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_health_no_arg() {
|
||||
let result = parse_decorator("# @health", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_pipe_no_arg() {
|
||||
let result = parse_decorator("# @pipe", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_pipe_empty_parens() {
|
||||
let result = parse_decorator("# @pipe()", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_pipe_only_commas() {
|
||||
let result = parse_decorator("# @pipe(,,)", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests for parse_decorator - Invalid: wrong argument types
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_loop_non_numeric() {
|
||||
let result = parse_decorator("# @loop(abc)", 1);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
if let BakeError::DecoratorParseError { reason, .. } = err {
|
||||
assert!(reason.contains("invalid loop count"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_loop_float() {
|
||||
let result = parse_decorator("# @loop(3.14)", 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_loop_negative() {
|
||||
let result = parse_decorator("# @loop(-5)", 1);
|
||||
// usize parsing will fail for negative numbers
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_timeout_non_numeric() {
|
||||
let result = parse_decorator("# @timeout(abc)", 1);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
if let BakeError::DecoratorParseError { reason, .. } = err {
|
||||
assert!(reason.contains("invalid timeout"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_retry_non_numeric_count() {
|
||||
let result = parse_decorator("# @retry(abc, 100)", 1);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
if let BakeError::DecoratorParseError { reason, .. } = err {
|
||||
assert!(reason.contains("invalid retry count"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_retry_non_numeric_delay() {
|
||||
let result = parse_decorator("# @retry(3, abc)", 1);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
if let BakeError::DecoratorParseError { reason, .. } = err {
|
||||
assert!(reason.contains("invalid retry delay"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_retry_empty_delay() {
|
||||
let result = parse_decorator("# @retry(3, )", 1);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
if let BakeError::DecoratorParseError { reason, .. } = err {
|
||||
assert!(reason.contains("no delay specified"));
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests for parse_decorator - Invalid: unknown decorators
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_unknown() {
|
||||
let result = parse_decorator("# @unknown", 1);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, BakeError::DecoratorParseError { .. }));
|
||||
if let BakeError::DecoratorParseError {
|
||||
decorator,
|
||||
reason,
|
||||
line_num,
|
||||
} = err
|
||||
{
|
||||
assert_eq!(decorator, "unknown");
|
||||
assert_eq!(line_num, 1);
|
||||
assert!(reason.contains("unknown decorator"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_typo_name() {
|
||||
let result = parse_decorator("# @pipelne", 1);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
if let BakeError::DecoratorParseError { decorator, .. } = err {
|
||||
assert_eq!(decorator, "pipelne");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_uppercase_name() {
|
||||
let result = parse_decorator("# @PIPELINE", 1);
|
||||
assert!(result.is_err());
|
||||
// Regex is case-sensitive, so it won't match @PIPELINE
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests for parse_decorator - Line number tracking
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_line_number_preserved() {
|
||||
let result = parse_decorator("# @pipeline", 42);
|
||||
assert!(result.is_ok());
|
||||
// Line number is preserved in error cases
|
||||
let result = parse_decorator("# @pipeline(invalid)", 42);
|
||||
assert!(result.is_err());
|
||||
if let BakeError::DecoratorParseError { line_num, .. } = result.unwrap_err() {
|
||||
assert_eq!(line_num, 42);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests for parse_decorator - Edge cases
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_very_large_loop_count() {
|
||||
let result = parse_decorator(&format!("# @loop({})", usize::MAX), 1);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), Some(Decorator::Loop(usize::MAX)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_very_large_timeout() {
|
||||
let result = parse_decorator(&format!("# @timeout({})", u32::MAX), 1);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), Some(Decorator::Timeout(u32::MAX)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_if_with_special_chars() {
|
||||
// Test condition with special characters that might break parsing
|
||||
let result = parse_decorator(r#"# @if(x != "" && y > 0)"#, 1).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(Decorator::If(r#"x != "" && y > 0"#.to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_pipe_with_underscores() {
|
||||
let result = parse_decorator("# @pipe(step_1, step_2, step_3)", 1).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(Decorator::Pipe(vec![
|
||||
"step_1".to_string(),
|
||||
"step_2".to_string(),
|
||||
"step_3".to_string()
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_decorator_multiple_in_sequence() {
|
||||
// Simulate multiple decorator lines
|
||||
let line1 = parse_decorator("# @pipeline", 1).unwrap();
|
||||
let line2 = parse_decorator("# @if(condition)", 2).unwrap();
|
||||
let line3 = parse_decorator("# @timeout(60)", 3).unwrap();
|
||||
|
||||
assert_eq!(line1, Some(Decorator::Pipeline));
|
||||
assert_eq!(line2, Some(Decorator::If("condition".to_string())));
|
||||
assert_eq!(line3, Some(Decorator::Timeout(60)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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}: {reason}")]
|
||||
ScriptGenerationFailed { script: 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: {0}")]
|
||||
IoError(#[from] 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),
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
use super::decorator::{Decorator, parse_decorator};
|
||||
use crate::bake::error::BakeError;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use shlex::Shlex;
|
||||
|
||||
lazy_static! {
|
||||
static ref FUNCTION_REGEX: Regex =
|
||||
Regex::new(r"^\s*(function\s*)([a-zA-Z0-9_-]+)\s*(\(\))?").unwrap();
|
||||
static ref FUNCTION_REGEX_WITHOUT_KEYWORD: Regex =
|
||||
Regex::new(r"^\s*([a-zA-Z0-9_-]+)\s*(\(\))").unwrap();
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ParserState {
|
||||
Normal,
|
||||
AfterDecorator,
|
||||
InFunction {
|
||||
name: String,
|
||||
decorators: Vec<Decorator>,
|
||||
body: String,
|
||||
brace_depth: i16,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let mut state = ParserState::Normal;
|
||||
let mut pending_decorators: Vec<Decorator> = Vec::new();
|
||||
|
||||
for (line_number, line) in text.lines().enumerate() {
|
||||
let line_number = line_number + 1;
|
||||
match &mut state {
|
||||
ParserState::Normal => {
|
||||
if DECORATOR_HEADER_REGEX.find(line).is_some() {
|
||||
if let Some(decorator) = parse_decorator(line, line_number)? {
|
||||
pending_decorators.push(decorator);
|
||||
}
|
||||
state = ParserState::AfterDecorator;
|
||||
continue;
|
||||
}
|
||||
if FUNCTION_REGEX.find(line).is_some()
|
||||
|| FUNCTION_REGEX_WITHOUT_KEYWORD.find(line).is_some()
|
||||
{
|
||||
let tokens = Shlex::new(line).collect::<Vec<String>>();
|
||||
let (name, _has_keyword) = if tokens[0] == "function" {
|
||||
(FUNCTION_REGEX.captures(line).unwrap()[2].to_string(), true)
|
||||
} else {
|
||||
(
|
||||
FUNCTION_REGEX_WITHOUT_KEYWORD.captures(line).unwrap()[1].to_string(),
|
||||
false,
|
||||
)
|
||||
};
|
||||
let has_pipeline = pending_decorators
|
||||
.iter()
|
||||
.any(|d| matches!(d, Decorator::Pipeline));
|
||||
let brace_depth = tokens.iter().filter(|&token| token == "{").count() as i16
|
||||
- tokens.iter().filter(|&token| token == "}").count() as i16;
|
||||
|
||||
if brace_depth != 0 {
|
||||
state = ParserState::InFunction {
|
||||
name,
|
||||
decorators: pending_decorators.clone(),
|
||||
body: line.to_string() + "\n",
|
||||
brace_depth,
|
||||
};
|
||||
} else {
|
||||
if has_pipeline {
|
||||
pipeline_functions.push(Function {
|
||||
name: name.clone(),
|
||||
decorators: pending_decorators.clone(),
|
||||
body: line.to_string() + "\n",
|
||||
});
|
||||
} else {
|
||||
for dec in &pending_decorators {
|
||||
remaining_code.push_str(&format!("# {}\n", dec));
|
||||
}
|
||||
remaining_code.push_str(line);
|
||||
remaining_code.push('\n');
|
||||
}
|
||||
pending_decorators.clear();
|
||||
state = ParserState::Normal;
|
||||
}
|
||||
} else {
|
||||
remaining_code.push_str(line);
|
||||
remaining_code.push('\n');
|
||||
}
|
||||
}
|
||||
ParserState::AfterDecorator => {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if DECORATOR_HEADER_REGEX.find(line).is_some() {
|
||||
if let Some(decorator) = parse_decorator(line, line_number)? {
|
||||
pending_decorators.push(decorator);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if line.trim().starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if FUNCTION_REGEX.find(line).is_some()
|
||||
|| FUNCTION_REGEX_WITHOUT_KEYWORD.find(line).is_some()
|
||||
{
|
||||
let tokens = Shlex::new(line).collect::<Vec<String>>();
|
||||
let (name, _has_keyword) = if tokens[0] == "function" {
|
||||
(FUNCTION_REGEX.captures(line).unwrap()[2].to_string(), true)
|
||||
} else {
|
||||
(
|
||||
FUNCTION_REGEX_WITHOUT_KEYWORD.captures(line).unwrap()[1].to_string(),
|
||||
false,
|
||||
)
|
||||
};
|
||||
let has_pipeline = pending_decorators
|
||||
.iter()
|
||||
.any(|d| matches!(d, Decorator::Pipeline));
|
||||
let brace_depth = tokens.iter().filter(|&token| token == "{").count() as i16
|
||||
- tokens.iter().filter(|&token| token == "}").count() as i16;
|
||||
|
||||
if brace_depth != 0 {
|
||||
state = ParserState::InFunction {
|
||||
name,
|
||||
decorators: pending_decorators.clone(),
|
||||
body: line.to_string() + "\n",
|
||||
brace_depth,
|
||||
};
|
||||
} else {
|
||||
if has_pipeline {
|
||||
pipeline_functions.push(Function {
|
||||
name: name.clone(),
|
||||
decorators: pending_decorators.clone(),
|
||||
body: line.to_string() + "\n",
|
||||
});
|
||||
} else {
|
||||
for dec in &pending_decorators {
|
||||
remaining_code.push_str(&format!("# {}\n", dec));
|
||||
}
|
||||
remaining_code.push_str(line);
|
||||
remaining_code.push('\n');
|
||||
}
|
||||
pending_decorators.clear();
|
||||
state = ParserState::Normal;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
eprintln!(
|
||||
"Warning: Dangling decorator(s) at line {}. Treating as comments.",
|
||||
line_number - 1
|
||||
);
|
||||
for dec in &pending_decorators {
|
||||
remaining_code.push_str(&format!("# {}\n", dec));
|
||||
}
|
||||
pending_decorators.clear();
|
||||
state = ParserState::Normal;
|
||||
remaining_code.push_str(line);
|
||||
remaining_code.push('\n');
|
||||
}
|
||||
ParserState::InFunction {
|
||||
name,
|
||||
decorators,
|
||||
body,
|
||||
brace_depth,
|
||||
} => {
|
||||
let has_pipeline = decorators.iter().any(|d| matches!(d, Decorator::Pipeline));
|
||||
body.push_str(line);
|
||||
body.push('\n');
|
||||
let tokens = Shlex::new(line).collect::<Vec<String>>();
|
||||
*brace_depth += tokens.iter().filter(|&token| token == "{").count() as i16;
|
||||
*brace_depth -= tokens.iter().filter(|&token| token == "}").count() as i16;
|
||||
if *brace_depth == 0 && !body.trim().is_empty() {
|
||||
if has_pipeline {
|
||||
pipeline_functions.push(Function {
|
||||
name: name.clone(),
|
||||
decorators: decorators.clone(),
|
||||
body: body.clone(),
|
||||
});
|
||||
} else {
|
||||
for dec in decorators {
|
||||
remaining_code.push_str(&format!("# {}\n", dec));
|
||||
}
|
||||
remaining_code.push_str(body);
|
||||
}
|
||||
pending_decorators.clear();
|
||||
state = ParserState::Normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let ParserState::InFunction {
|
||||
name, brace_depth, ..
|
||||
} = state
|
||||
{
|
||||
return Err(BakeError::ScriptGenerationFailed {
|
||||
script: "bake.sh".to_string(),
|
||||
reason: format!(
|
||||
"Unexpected EOF while parsing function '{}'. Unmatched braces (remaining: {})?",
|
||||
name, brace_depth
|
||||
),
|
||||
});
|
||||
}
|
||||
if !pending_decorators.is_empty() {
|
||||
let last_decorator = pending_decorators.last().unwrap();
|
||||
return Err(BakeError::ScriptGenerationFailed {
|
||||
script: "bake.sh".to_string(),
|
||||
reason: format!(
|
||||
"Dangling decorator {} before EOF. Missing function definition?",
|
||||
last_decorator
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ParsedScript {
|
||||
pipeline_functions,
|
||||
remaining_code,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_empty_script() {
|
||||
let script = "";
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert!(result.pipeline_functions.is_empty());
|
||||
assert!(result.remaining_code.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_only_comments() {
|
||||
let script = "# comment\n# another";
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert!(result.pipeline_functions.is_empty());
|
||||
assert_eq!(result.remaining_code.trim(), "# comment\n# another");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_function_with_parens_non_pipeline() {
|
||||
let script = "func() { echo hi; }";
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert!(result.pipeline_functions.is_empty());
|
||||
assert!(result.remaining_code.contains("func()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pipeline_function() {
|
||||
let script = "# @pipeline\nfunc() { echo hi; }";
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 1);
|
||||
assert_eq!(result.pipeline_functions[0].name, "func");
|
||||
assert!(result.remaining_code.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_functions() {
|
||||
let script = r#"#!/bin/bash
|
||||
# comment
|
||||
function helper() { echo "helper"; }
|
||||
# @pipeline
|
||||
prepare() { echo "prepare"; }
|
||||
echo "Done."
|
||||
"#;
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 1);
|
||||
assert_eq!(result.pipeline_functions[0].name, "prepare");
|
||||
assert!(result.remaining_code.contains("# comment"));
|
||||
assert!(result.remaining_code.contains("helper()"));
|
||||
assert!(result.remaining_code.contains("echo \"Done.\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_only_pipeline_functions_remaining_empty() {
|
||||
let script = "# @pipeline\nfunc1() { echo 1; }\n# @pipeline\nfunc2() { echo 2; }";
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 2);
|
||||
assert!(result.remaining_code.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_example() {
|
||||
let script = r#"#!/bin/bash
|
||||
|
||||
#Implicit set -euo pipefail
|
||||
|
||||
# function to be used by any pipeline stage
|
||||
function get_custom_library() {
|
||||
curl -LO https://archive.ppy.sh/$2/$1.tar.gz
|
||||
}
|
||||
|
||||
# @pipeline
|
||||
prepare_dotnet() {
|
||||
curl -L https://dot.net/v1/dotnet-install.sh | bash
|
||||
bake_info "Successfully installed dotnet"
|
||||
}
|
||||
|
||||
# @pipeline
|
||||
fetch_source() {
|
||||
git clone https://github.com/ppy/osu.git --depth 1
|
||||
}
|
||||
|
||||
echo "Done."
|
||||
"#;
|
||||
let result = parse_script(&script.to_string());
|
||||
match result {
|
||||
Ok(r) => {
|
||||
assert_eq!(
|
||||
r.pipeline_functions.len(),
|
||||
2,
|
||||
"Expected 2 pipeline functions, got {}",
|
||||
r.pipeline_functions.len()
|
||||
);
|
||||
assert_eq!(r.pipeline_functions[0].name, "prepare_dotnet");
|
||||
assert_eq!(r.pipeline_functions[1].name, "fetch_source");
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("Parse error: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blank_lines_between_decorator_and_function() {
|
||||
let script = "# @pipeline\n\nfunc() { echo 1; }";
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 1);
|
||||
assert_eq!(result.pipeline_functions[0].name, "func");
|
||||
assert!(result.remaining_code.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_blank_lines_between_decorator_and_function() {
|
||||
let script = "# @pipeline\n\n\nfunc() { echo 1; }";
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 1);
|
||||
assert_eq!(result.pipeline_functions[0].name, "func");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comments_between_decorator_and_function() {
|
||||
let script = "# @pipeline\n# comment\nfunc() { echo 1; }";
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 1);
|
||||
assert_eq!(result.pipeline_functions[0].name, "func");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_comments_between_decorator_and_function() {
|
||||
let script = "# @pipeline\n# comment 1\n# comment 2\nfunc() { echo 1; }";
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 1);
|
||||
assert_eq!(result.pipeline_functions[0].name, "func");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_blank_lines_and_comments_between_decorator_and_function() {
|
||||
let script = "# @pipeline\n# comment\n\n# another comment\n\nfunc() { echo 1; }";
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 1);
|
||||
assert_eq!(result.pipeline_functions[0].name, "func");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_statement_after_decorator_warns() {
|
||||
let script = "# @pipeline\necho \"Done.\"";
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 0);
|
||||
assert!(result.remaining_code.contains("# @pipeline"));
|
||||
assert!(result.remaining_code.contains("echo \"Done.\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nested_braces_in_string() {
|
||||
let script = r#"# @pipeline
|
||||
func() { echo "{"; }"#;
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 1);
|
||||
assert_eq!(result.pipeline_functions[0].name, "func");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nested_braces_in_double_string() {
|
||||
let script = r#"# @pipeline
|
||||
func() { echo "{}"; }"#;
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 1);
|
||||
assert_eq!(result.pipeline_functions[0].name, "func");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nested_braces_in_code() {
|
||||
let script = r#"# @pipeline
|
||||
func() {
|
||||
if [ -z "${VAR}" ]; then
|
||||
echo "empty"
|
||||
fi
|
||||
}"#;
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 1);
|
||||
assert_eq!(result.pipeline_functions[0].name, "func");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_function_with_curl_braces_url() {
|
||||
let script = r#"# @pipeline
|
||||
func() {
|
||||
curl -LO https://example.com/file.tar.gz
|
||||
}"#;
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_function_with_json_like_content() {
|
||||
let script = r#"# @pipeline
|
||||
func() { echo '{"key": "value"}'; }"#;
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorator_with_blank_lines_then_shell_statement() {
|
||||
let script = "# @pipeline\n\n\necho \"Done.\"";
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 0);
|
||||
assert!(result.remaining_code.contains("# @pipeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_decorators_then_function() {
|
||||
let script = "# @pipeline\n# @fallible\n\n\nfunc() { echo 1; }";
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 1);
|
||||
assert_eq!(result.pipeline_functions[0].decorators.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_decorators_then_shell_statement() {
|
||||
let script = "# @pipeline\n# @fallible\necho \"Done.\"";
|
||||
let result = parse_script(&script.to_string()).unwrap();
|
||||
assert_eq!(result.pipeline_functions.len(), 0);
|
||||
assert!(result.remaining_code.contains("# @pipeline"));
|
||||
assert!(result.remaining_code.contains("# @fallible"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
use super::{decorator::Decorator, parser::Function};
|
||||
use crate::bake::error::BakeError;
|
||||
use std::collections::HashMap;
|
||||
use topological_sort::TopologicalSort;
|
||||
|
||||
/// Topologically sorts functions by explicit `@after` dependencies and implicit ordering
|
||||
/// for consecutive `@pipeline` functions (each pipeline function implicitly depends on
|
||||
/// the previous one unless it has an explicit `@after`).
|
||||
pub fn sort_function(functions: Vec<Function>) -> Result<Vec<Function>, BakeError> {
|
||||
let function_mapped = functions
|
||||
.iter()
|
||||
.map(|f| (f.name.as_str(), f))
|
||||
.collect::<HashMap<&str, &Function>>();
|
||||
|
||||
let mut ts = TopologicalSort::<String>::new();
|
||||
|
||||
// Track last pipeline function for implicit @after
|
||||
let mut last_pipeline: Option<String> = None;
|
||||
|
||||
for function in &functions {
|
||||
ts.insert(function.name.clone());
|
||||
|
||||
let is_pipeline = function
|
||||
.decorators
|
||||
.iter()
|
||||
.any(|d| matches!(d, Decorator::Pipeline));
|
||||
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());
|
||||
}
|
||||
last_pipeline = Some(function.name.clone());
|
||||
}
|
||||
|
||||
for decorator in &function.decorators {
|
||||
if let Decorator::After(dependency) = decorator {
|
||||
if !function_mapped.contains_key(dependency.as_str()) {
|
||||
log::error!(
|
||||
"Unknown dependency of function {}: {}",
|
||||
function.name,
|
||||
dependency
|
||||
);
|
||||
return Err(BakeError::UnknownDependency(
|
||||
function.name.clone(),
|
||||
dependency.clone(),
|
||||
));
|
||||
}
|
||||
ts.add_dependency(dependency, function.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
if !ts.is_empty() {
|
||||
let remaining: Vec<String> = ts.collect();
|
||||
return Err(BakeError::CircularDependency(remaining));
|
||||
}
|
||||
|
||||
Ok(functions_sorted)
|
||||
}
|
||||
|
||||
#[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(),
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
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};
|
||||
fn or_workshop(path: &Option<PathBuf>, name: &str) -> PathBuf {
|
||||
path
|
||||
.clone()
|
||||
.unwrap_or_else(|| PathBuf::from(format!(".workshop/{}", name)))
|
||||
}
|
||||
|
||||
pub async fn run_bare(
|
||||
cli: &Cli,
|
||||
command: &BareCommands,
|
||||
ctx: &mut ExecutionContext,
|
||||
event_tx: EventSender,
|
||||
pipeline: &str,
|
||||
build_id: &str,
|
||||
registry: &ResourceRegistry,
|
||||
) -> Result<(), CliError> {
|
||||
let (stage, config) = match command {
|
||||
BareCommands::Prebake { .. } => ("prebake", or_workshop(&cli.prebake, "prebake.yml")),
|
||||
BareCommands::Bake { .. } => ("bake", or_workshop(&cli.bake, "bake.sh")),
|
||||
BareCommands::Finalize { .. } => {
|
||||
("finalize", or_workshop(&cli.finalize, "finalize.yml"))
|
||||
}
|
||||
};
|
||||
|
||||
let prebake_path = or_workshop(&cli.prebake, "prebake.yml");
|
||||
let bake_base = or_workshop(&cli.bake_base, "bake_base.sh");
|
||||
|
||||
ctx.task_id = format!("{}-{}-{}", pipeline, build_id, stage);
|
||||
ctx.pipeline_name = pipeline.to_string();
|
||||
ctx.standalone = true;
|
||||
|
||||
match command {
|
||||
BareCommands::Prebake { .. } => {
|
||||
prebake(&config, cli, None, ctx, event_tx, None).await?;
|
||||
}
|
||||
BareCommands::Bake { .. } => {
|
||||
bake(&config, &bake_base, &prebake_path, cli, ctx, event_tx).await?;
|
||||
}
|
||||
BareCommands::Finalize { .. } => {
|
||||
finalize(&config, cli, ctx, event_tx, registry).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use thiserror::Error;
|
||||
use workshop_engine::HasExitCode;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum CliError {
|
||||
#[error("{0}")]
|
||||
Prebake(#[from] crate::prebake::error::PrebakeError),
|
||||
#[error("{0}")]
|
||||
Bake(#[from] crate::bake::error::BakeError),
|
||||
#[error("{0}")]
|
||||
Finalize(#[from] crate::finalize::FinalizeError),
|
||||
#[error("{0}")]
|
||||
General(anyhow::Error),
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for CliError {
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
CliError::General(e)
|
||||
}
|
||||
}
|
||||
impl CliError {
|
||||
pub fn exit_code(&self) -> i32 {
|
||||
match self {
|
||||
CliError::Prebake(e) => e.exit_code(),
|
||||
CliError::Bake(e) => e.exit_code(),
|
||||
CliError::Finalize(e) => e.exit_code(),
|
||||
CliError::General(_) => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
pub mod config;
|
||||
pub mod constant;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod plugin;
|
||||
pub mod stage;
|
||||
pub mod template;
|
||||
pub mod types;
|
||||
|
||||
use crate::cli::Cli;
|
||||
use crate::types::resource::ResourceRegistry;
|
||||
pub use config::*;
|
||||
pub use error::FinalizeError;
|
||||
use std::path::Path;
|
||||
use workshop_engine::EventSender;
|
||||
|
||||
pub fn parse(path: &Path) -> Result<FinalizeConfig, FinalizeError> {
|
||||
let content = std::fs::read_to_string(path).map_err(FinalizeError::IoError)?;
|
||||
let config: FinalizeConfig = serde_yaml::from_str(&content)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub async fn finalize(
|
||||
finalize_path: &Path,
|
||||
_cli: &Cli,
|
||||
ctx: &mut workshop_engine::ExecutionContext,
|
||||
event_tx: EventSender,
|
||||
registry: &ResourceRegistry,
|
||||
) -> Result<(), FinalizeError> {
|
||||
let mut finalize = parse(finalize_path)?;
|
||||
let validate_result = finalize.validate();
|
||||
if let Err(errors) = validate_result {
|
||||
for error in &errors {
|
||||
log::error!("Error: {}", error);
|
||||
}
|
||||
return Err(FinalizeError::ValidateError(errors));
|
||||
};
|
||||
|
||||
// Register Plugins (paths resolved from registry)
|
||||
log::info!("Registering {} plugins", finalize.plugin.len());
|
||||
let registered_plugins = plugin::register(finalize.plugin, None, &mut finalize.notification, registry)?;
|
||||
|
||||
// Early hook
|
||||
let earlyhook_result = stage::hook::hook(
|
||||
finalize.hooks.as_ref().and_then(|h| h.early.as_ref()),
|
||||
ctx,
|
||||
event_tx.clone(),
|
||||
®istered_plugins,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Determine if we should continue with artifacts/latehook
|
||||
// If prior stages failed, we still send notification but skip artifacts
|
||||
let _prior_failed = earlyhook_result.is_err();
|
||||
|
||||
// Prepare artifacts
|
||||
// TODO: artifact stage implementation
|
||||
|
||||
// Late hook
|
||||
stage::hook::hook(
|
||||
finalize.hooks.as_ref().and_then(|h| h.late.as_ref()),
|
||||
ctx,
|
||||
event_tx.clone(),
|
||||
®istered_plugins,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# workshop-baker/src/finalize
|
||||
|
||||
**Generated:** 2026-05-19
|
||||
**Commit:** 28abc5d
|
||||
|
||||
Artifact packaging, plugin-based notifications, and post-build hooks.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
finalize/
|
||||
├── config.rs # FinalizeConfig YAML (379 lines, plugins, notifications)
|
||||
├── stage.rs # FinalizeStage enum
|
||||
├── stage/hook.rs # Post-build hook execution
|
||||
├── event.rs # Event propagation
|
||||
├── template.rs # Variable substitution {{VARIABLE}}
|
||||
├── plugin.rs # Plugin trait + registration
|
||||
├── plugin/
|
||||
│ ├── metadata.rs # Plugin manifest parsing
|
||||
│ ├── dylib.rs # Dynamic library plugins
|
||||
│ ├── rhai.rs # Rhai scripting (todo!())
|
||||
│ ├── shell.rs # Shell command plugins
|
||||
│ └── internal.rs + internal/ # Built-in plugins (mail, webhook, satori, insitenotify)
|
||||
├── types.rs
|
||||
├── types/compression.rs
|
||||
├── error.rs # FinalizeError
|
||||
└── constant.rs
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location |
|
||||
|------|----------|
|
||||
| Config loading | `config.rs:17` - `FinalizeConfig` + `parse()` |
|
||||
| Plugin registration | `plugin.rs` - `register()` |
|
||||
| Email notifications | `plugin/internal/mail.rs` - SMTP |
|
||||
| Webhooks | `plugin/internal/webhook.rs` |
|
||||
| Satori integration | `plugin/internal/satori.rs` |
|
||||
| Shell plugins | `plugin/shell.rs` |
|
||||
| Template rendering | `template.rs` - `{{VARIABLE}}` handlebars-style |
|
||||
|
||||
## CONVENTIONS
|
||||
- **Plugin System**: `FinalizePlugin` trait + registry with both internal and dynamic plugins
|
||||
- **Template Syntax**: `{{VARIABLE}}` handlebars-style substitution via `template.rs`
|
||||
- **Stages**: EarlyHook → (Artifacts) → LateHook → Notifications
|
||||
- **Notification**: `notify.rs` in parent module drives plugin-based notification dispatch
|
||||
- **ResourceRegistry**: Shared resource cache passed through to plugins
|
||||
|
||||
## ANTI-PATTERNS
|
||||
1. **fetch/ subdirectory removed** — was `plugin/fetch/{git,http,extract,checksum}`, no longer exists
|
||||
2. **SHA1 deprecated** — workshop-getterurl handles checksums now
|
||||
3. **Unimplemented** — `rhai.rs:17` - "todo!()" for Rhai scripting
|
||||
4. **TODO markers** — Multiple TODOs in config.rs (lines 305-306)
|
||||
5. **Artifact stage not implemented** — `finalize.rs:57` — "TODO: artifact stage implementation"
|
||||
@@ -0,0 +1,454 @@
|
||||
//! Finalize stage configuration types
|
||||
//!
|
||||
//! This module defines the configuration schema for the finalize stage,
|
||||
//! which handles artifact collection, publishing, and notifications.
|
||||
|
||||
use crate::finalize::types::compression::CompressionMethod;
|
||||
use semver::{Version, VersionReq};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use workshop_engine::{CustomCommand, deserialize_duration_ms};
|
||||
|
||||
/// Version requirement for finalize.yml schema compatibility
|
||||
pub const VERSION_REQUIREMENT: &str = "^0.0.1";
|
||||
|
||||
pub type RawPluginMap = HashMap<String, HashMap<String, PluginConfig>>;
|
||||
|
||||
/// Root configuration for finalize stage
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct FinalizeConfig {
|
||||
/// Schema version for compatibility checking
|
||||
pub version: String,
|
||||
|
||||
/// Plugin definitions
|
||||
/// Map of logical plugin name -> plugin source URI -> plugin config
|
||||
#[serde(default)]
|
||||
pub plugin: RawPluginMap,
|
||||
|
||||
/// Notification configuration (templates + priority groups)
|
||||
#[serde(default)]
|
||||
pub notification: NotificationConfig,
|
||||
|
||||
/// Artifact definitions
|
||||
#[serde(default)]
|
||||
pub artifact: Vec<ArtifactDef>,
|
||||
|
||||
/// Cleanup configuration
|
||||
#[serde(default)]
|
||||
pub cleanup: CleanupConfig,
|
||||
|
||||
/// Hooks configuration
|
||||
#[serde(default)]
|
||||
pub hooks: Option<FinalizeHooks>,
|
||||
}
|
||||
|
||||
/// Plugin configuration
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct PluginConfig {
|
||||
/// Whether plugin failure should not fail the build.
|
||||
/// Same as @fallible decorator
|
||||
#[serde(default)]
|
||||
pub fallible: bool,
|
||||
|
||||
/// Number of retries on failure (0 = no retry, -1 = infinite retries)
|
||||
#[serde(default)]
|
||||
pub retry: i8,
|
||||
|
||||
/// Timeout for plugin execution in milliseconds (0 = no timeout)
|
||||
#[serde(default)]
|
||||
pub timeout_ms: u64,
|
||||
|
||||
/// Whether the plugin should be resolved after bake stage
|
||||
#[serde(default)]
|
||||
pub late_binding: bool,
|
||||
|
||||
/// (Git) Enable shallow clone
|
||||
#[serde(default)]
|
||||
pub shallow: Option<bool>,
|
||||
|
||||
/// (Http) Checksum of the plugin package
|
||||
#[serde(default)]
|
||||
pub checksum: Option<String>,
|
||||
|
||||
/// Baker runtime configuration
|
||||
#[serde(skip)]
|
||||
pub runtime: PluginRuntimeConfig,
|
||||
|
||||
/// Plugin-specific configuration parameters.
|
||||
/// These are passed to the plugin via according method.
|
||||
#[serde(flatten)]
|
||||
pub config: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PluginRuntimeConfig {
|
||||
/// Error description if plugin is unavailable at runtime.
|
||||
/// None means available
|
||||
pub error: Option<String>,
|
||||
|
||||
/// Path of plugin directory in the environment
|
||||
pub fspath: Option<PathBuf>,
|
||||
|
||||
/// manifest.yml
|
||||
pub manifest: Option<Value>,
|
||||
}
|
||||
|
||||
/// Notification method configuration
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct NotificationMethod {
|
||||
/// Template definition
|
||||
#[serde(default)]
|
||||
pub template: NotificationTemplate,
|
||||
|
||||
/// Trigger definition
|
||||
#[serde(default)]
|
||||
pub trigger: Vec<TriggerItemRaw>,
|
||||
|
||||
/// Notification-specific configuration (url, token, to, from, etc.)
|
||||
#[serde(flatten)]
|
||||
pub config: Value,
|
||||
|
||||
/// Policy list for flexible trigger/template/config combinations.
|
||||
#[serde(default)]
|
||||
pub policy: Vec<NotifyPolicy>,
|
||||
}
|
||||
|
||||
impl Default for NotificationMethod {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
template: NotificationTemplate::default(),
|
||||
trigger: Vec::new(),
|
||||
config: Value::default(),
|
||||
policy: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
pub struct NotificationTemplate {
|
||||
/// Payload schema: "default" (plugin-defined) or "custom" (requires on_* templates)
|
||||
#[serde(default)]
|
||||
pub schema: Option<String>,
|
||||
|
||||
/// Content template for build success notification
|
||||
#[serde(default)]
|
||||
pub on_success: Option<String>,
|
||||
|
||||
/// Content template for build failure notification
|
||||
#[serde(default)]
|
||||
pub on_failure: Option<String>,
|
||||
|
||||
/// Stage-triggered notification configuration
|
||||
#[serde(default)]
|
||||
pub on_finish_of: Option<OnFinishOf>,
|
||||
}
|
||||
|
||||
impl std::ops::AddAssign for NotificationTemplate {
|
||||
fn add_assign(&mut self, other: Self) {
|
||||
if self.schema.is_none() {
|
||||
self.schema = other.schema;
|
||||
}
|
||||
if self.on_success.is_none() {
|
||||
self.on_success = other.on_success;
|
||||
}
|
||||
if self.on_failure.is_none() {
|
||||
self.on_failure = other.on_failure;
|
||||
}
|
||||
if self.on_finish_of.is_none() {
|
||||
self.on_finish_of = other.on_finish_of;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Notification configuration container
|
||||
#[derive(Debug, Serialize, Clone, Default)]
|
||||
pub struct NotificationConfig {
|
||||
/// Template definitions for notification content
|
||||
#[serde(default)]
|
||||
pub templates: HashMap<String, NotificationTemplateDef>,
|
||||
|
||||
/// Priority groups (YAML keys like `0`, `1` are parsed as u32).
|
||||
/// Custom Deserialize handles serde_yaml string→u32 conversion.
|
||||
#[serde(default)]
|
||||
pub groups: HashMap<u32, HashMap<String, NotificationMethod>>,
|
||||
}
|
||||
|
||||
/// Serde helper: YAML map keys are always strings, convert to u32.
|
||||
impl<'de> serde::Deserialize<'de> for NotificationConfig {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where D: serde::Deserializer<'de>
|
||||
{
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Helper {
|
||||
#[serde(default)]
|
||||
templates: HashMap<String, NotificationTemplateDef>,
|
||||
#[serde(flatten)]
|
||||
groups_raw: HashMap<String, HashMap<String, NotificationMethod>>,
|
||||
}
|
||||
let h = Helper::deserialize(deserializer)?;
|
||||
let groups: HashMap<u32, _> = h.groups_raw.into_iter()
|
||||
.map(|(k, v)| {
|
||||
k.parse::<u32>().map(|n| (n, v))
|
||||
.map_err(|_| serde::de::Error::custom(format!("priority key must be integer, got {:?}", k)))
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
Ok(NotificationConfig { templates: h.templates, groups })
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage-triggered notification configuration
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct OnFinishOf {
|
||||
/// Stage name to trigger on
|
||||
/// Format: "prebake.ready", "bake.build", "finalize.artifact", etc.
|
||||
pub stage: String,
|
||||
|
||||
/// Content template for stage completion notification
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Notification template definition.
|
||||
/// Supports inline content (on_success/on_failure/on_finish_of) or external reference (use).
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
pub struct NotificationTemplateDef {
|
||||
/// External template reference (git URL).
|
||||
/// Cannot be combined with on_* fields in MVP (validation will reject).
|
||||
#[serde(default, rename = "use")]
|
||||
pub use_: Option<String>,
|
||||
|
||||
/// Content template for build success notification
|
||||
#[serde(default)]
|
||||
pub on_success: Option<String>,
|
||||
|
||||
/// Content template for build failure notification
|
||||
#[serde(default)]
|
||||
pub on_failure: Option<String>,
|
||||
|
||||
/// Content template for stage completion notification
|
||||
#[serde(default)]
|
||||
pub on_finish_of: Option<String>,
|
||||
}
|
||||
|
||||
impl NotificationTemplateDef {
|
||||
/// Merge `other` into `self`, keeping self's values when present.
|
||||
///
|
||||
/// `other` fills in gaps where `self` is `None`.
|
||||
/// Chaining: `a.apply(b).apply(c)` = a wins, then b fills gaps, then c fills gaps.
|
||||
pub fn apply(mut self, other: Self) -> Self {
|
||||
if self.on_success.is_none() {
|
||||
self.on_success = other.on_success;
|
||||
}
|
||||
if self.on_failure.is_none() {
|
||||
self.on_failure = other.on_failure;
|
||||
}
|
||||
if self.on_finish_of.is_none() {
|
||||
self.on_finish_of = other.on_finish_of;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for [`NotificationTemplateDef`].
|
||||
pub struct NotificationTemplateDefBuilder(NotificationTemplateDef);
|
||||
|
||||
impl NotificationTemplateDefBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self(NotificationTemplateDef::default())
|
||||
}
|
||||
pub fn with_use(mut self, val: impl Into<String>) -> Self {
|
||||
self.0.use_ = Some(val.into());
|
||||
self
|
||||
}
|
||||
pub fn with_on_success(mut self, val: impl Into<String>) -> Self {
|
||||
self.0.on_success = Some(val.into());
|
||||
self
|
||||
}
|
||||
pub fn with_on_failure(mut self, val: impl Into<String>) -> Self {
|
||||
self.0.on_failure = Some(val.into());
|
||||
self
|
||||
}
|
||||
pub fn with_on_finish_of(mut self, val: impl Into<String>) -> Self {
|
||||
self.0.on_finish_of = Some(val.into());
|
||||
self
|
||||
}
|
||||
pub fn build(self) -> NotificationTemplateDef {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Notification trigger type.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Trigger {
|
||||
OnSuccess,
|
||||
OnFailure,
|
||||
OnFinishOf(String),
|
||||
}
|
||||
|
||||
/// Raw trigger item for deserialization.
|
||||
/// Supports `- on_success` (string) and `- on_finish_of: [...]` (map).
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum TriggerItemRaw {
|
||||
Simple(String),
|
||||
Map(std::collections::HashMap<String, Vec<String>>),
|
||||
}
|
||||
|
||||
/// Notification policy: a set of triggers with optional template/config overrides.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct NotifyPolicy {
|
||||
/// Trigger conditions for this policy.
|
||||
/// Empty means inheriting method-level triggers.
|
||||
#[serde(default)]
|
||||
pub trigger: Vec<TriggerItemRaw>,
|
||||
|
||||
/// Template configuration (schema, on_success, on_failure, on_finish_of, subject, body...).
|
||||
/// Empty/null means inheriting method-level templates.
|
||||
#[serde(default)]
|
||||
pub template: Value,
|
||||
|
||||
/// Override fields: to, url, token, fallible, retry, timeout_ms, etc.
|
||||
#[serde(flatten)]
|
||||
pub overrides: Value,
|
||||
}
|
||||
|
||||
/// Artifact definition
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct ArtifactDef {
|
||||
/// Artifact ID (optional, auto-generated as MD5(PUID + PATH) if not specified)
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
|
||||
/// Artifact path (supports glob patterns, empty for non-filesystem artifacts like Docker images)
|
||||
#[serde(default)]
|
||||
pub path: String,
|
||||
|
||||
/// Retention duration in milliseconds
|
||||
/// Formats: "7d", "168h", "604800" (seconds), 604800 (number=seconds)
|
||||
/// 0 = permanent retention
|
||||
#[serde(
|
||||
default = "default_retention_ms",
|
||||
deserialize_with = "deserialize_duration_ms"
|
||||
)]
|
||||
pub retention_ms: u64,
|
||||
|
||||
/// Compression method: "zstd", "gzip", "none", "dir"
|
||||
#[serde(default)]
|
||||
pub compression: CompressionMethod,
|
||||
// Publishing targets
|
||||
// TODO: under heavy refactor
|
||||
}
|
||||
|
||||
impl Default for ArtifactDef {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
path: String::new(),
|
||||
retention_ms: default_retention_ms(),
|
||||
compression: CompressionMethod::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Artifact
|
||||
/// TODO: Under heavy refactor
|
||||
/// Cleanup configuration
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
pub struct CleanupConfig {
|
||||
/// Cleanup policy: "auto", "manual", "on_failure" (WIP: manual, on_failure)
|
||||
#[serde(default)]
|
||||
pub policy: CleanupPolicy,
|
||||
}
|
||||
|
||||
/// Cleanup policy
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CleanupPolicy {
|
||||
#[default]
|
||||
Auto,
|
||||
Manual,
|
||||
OnSuccess,
|
||||
Never,
|
||||
}
|
||||
|
||||
/// Hooks configuration for finalize stage
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
pub struct FinalizeHooks {
|
||||
/// Hooks executed before artifact collection
|
||||
#[serde(default)]
|
||||
pub early: Option<Vec<CustomCommand>>,
|
||||
|
||||
/// Hooks executed after all operations complete
|
||||
#[serde(default)]
|
||||
pub late: Option<Vec<CustomCommand>>,
|
||||
}
|
||||
|
||||
// Default value functions
|
||||
fn default_retention_ms() -> u64 {
|
||||
7 * 24 * 3600 * 1000
|
||||
}
|
||||
|
||||
impl FinalizeConfig {
|
||||
/// Validate the finalize configuration
|
||||
pub fn validate(&self) -> Result<(), Vec<String>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// Validate version
|
||||
let version_requirement =
|
||||
VersionReq::parse(VERSION_REQUIREMENT).expect("Invalid version requirement");
|
||||
let version = &self.version;
|
||||
let version = match version.matches('.').count() {
|
||||
0 => format!("{}.0.0", version),
|
||||
1 => format!("{}.0", version),
|
||||
_ => version.clone(),
|
||||
};
|
||||
match Version::parse(&version) {
|
||||
Ok(version) if version_requirement.matches(&version) => {}
|
||||
Ok(version) => errors.push(format!(
|
||||
"Version {} does not satisfy requirement {}",
|
||||
version, VERSION_REQUIREMENT
|
||||
)),
|
||||
Err(e) => errors.push(format!("Invalid version format: {}", e)),
|
||||
}
|
||||
|
||||
// Validate plugin definitions: each plugin must have exactly one URL
|
||||
for (plugin_name, urls) in &self.plugin {
|
||||
match urls.len() {
|
||||
0 => errors.push(format!("Plugin '{}' has no URL defined", plugin_name)),
|
||||
1 => {}
|
||||
n => errors.push(format!(
|
||||
"Plugin '{}' has {} URL definitions, expected exactly 1",
|
||||
plugin_name, n
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
self.validate_notification(&mut errors);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_notification(&self, errors: &mut Vec<String>) {
|
||||
// Validate templates
|
||||
for (name, tmpl) in &self.notification.templates {
|
||||
if let Some(ref u) = tmpl.use_ {
|
||||
if u.contains("://") && (
|
||||
tmpl.on_success.is_some() ||
|
||||
tmpl.on_failure.is_some() ||
|
||||
tmpl.on_finish_of.is_some()
|
||||
) {
|
||||
errors.push(format!(
|
||||
"Template '{}' cannot combine external 'use' with 'on_*' fields",
|
||||
name
|
||||
));
|
||||
}
|
||||
// Local references (no ://) are allowed to have on_* overrides
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub const FINALIZE_PLUGIN_MANIFEST: &str = "manifest";
|
||||
pub const FINALIZE_PLUGIN_MANIFEST_EXTENSION: &[&str] = &[".yml", ".yaml"];
|
||||
pub const FINALIZE_SHELL_ENVPREFIX: &str = "HBW_ARG";
|
||||
|
||||
/// Standalone mode: directory where fetched plugins are stored
|
||||
pub const FINALIZE_STANDALONE_PLUGIN_PATH: &str = "/tmp/baker/plugin";
|
||||
@@ -0,0 +1,81 @@
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
use workshop_engine::{
|
||||
ExecutionError,
|
||||
error::{
|
||||
EXITCODE_EXECUTION_ERROR, EXITCODE_GENERAL_ERROR, EXITCODE_IO_ERROR, EXITCODE_PARSE_ERROR,
|
||||
HasExitCode,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum FinalizeError {
|
||||
#[error("Failed to parse finalize.yml: {0}")]
|
||||
YamlParseError(#[from] serde_yaml::Error),
|
||||
|
||||
#[error("Failed to validate finalize.yml: {0:?}")]
|
||||
ValidateError(Vec<String>),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
#[error("Artifact not found: {0}")]
|
||||
ArtifactNotFound(PathBuf),
|
||||
|
||||
#[error("Plugin error: {0}")]
|
||||
PluginError(#[from] PluginError),
|
||||
|
||||
#[error("Plugin execution failed: {0}")]
|
||||
PluginExecutionFailed(String),
|
||||
|
||||
#[error("Notification failed: {0}")]
|
||||
NotificationFailed(String),
|
||||
|
||||
#[error("Template error: {0}")]
|
||||
TemplateError(#[from] minijinja::Error),
|
||||
|
||||
#[error("Execution error: {0}")]
|
||||
ExecutionError(#[from] ExecutionError),
|
||||
}
|
||||
|
||||
impl HasExitCode for FinalizeError {
|
||||
fn exit_code(&self) -> i32 {
|
||||
match self {
|
||||
FinalizeError::YamlParseError(_) => EXITCODE_PARSE_ERROR,
|
||||
FinalizeError::ValidateError(_) => EXITCODE_PARSE_ERROR,
|
||||
FinalizeError::IoError(_) => EXITCODE_IO_ERROR,
|
||||
FinalizeError::ArtifactNotFound(_) => EXITCODE_GENERAL_ERROR,
|
||||
FinalizeError::PluginError(_) => EXITCODE_GENERAL_ERROR,
|
||||
FinalizeError::PluginExecutionFailed(_) => EXITCODE_EXECUTION_ERROR,
|
||||
FinalizeError::NotificationFailed(_) => EXITCODE_GENERAL_ERROR,
|
||||
FinalizeError::TemplateError(_) => EXITCODE_PARSE_ERROR,
|
||||
FinalizeError::ExecutionError(_) => EXITCODE_EXECUTION_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PluginError {
|
||||
#[error("Unknown plugin: {0}")]
|
||||
UnknownPlugin(String),
|
||||
|
||||
#[error("Error: {0}")]
|
||||
GeneralError(String),
|
||||
|
||||
#[error("Plugin {name} is not available: {reason}")]
|
||||
PluginUnavailable { name: String, reason: String },
|
||||
|
||||
#[error("Plugin execution failed: {0}")]
|
||||
ExecutionError(#[from] ExecutionError),
|
||||
|
||||
#[error("Failed to fetch plugin({name}:{url}): {reason}")]
|
||||
FetchError {
|
||||
name: String,
|
||||
url: String,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("Plugin manifest {0}/manifest.yml not found.")]
|
||||
ManifestNotFound(PathBuf),
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use workshop_engine::{ExecutionEvent, StreamType};
|
||||
|
||||
/// Receives and processes execution events for the finalize stage.
|
||||
/// Logs task lifecycle events (started, completed, failed) and streaming output (stdout/stderr)
|
||||
/// for the pipeline build identified by the given pipeline_name and build_id.
|
||||
pub async fn event_receiver(
|
||||
mut rx: tokio::sync::mpsc::Receiver<ExecutionEvent>,
|
||||
pipeline_name: String,
|
||||
build_id: String,
|
||||
) {
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
ExecutionEvent::TaskStarted { task_id, timestamp } => {
|
||||
log::debug!(
|
||||
"[{}] [{}] [{}] Task started: {}",
|
||||
pipeline_name,
|
||||
build_id,
|
||||
timestamp,
|
||||
task_id
|
||||
);
|
||||
}
|
||||
ExecutionEvent::OutputChunk {
|
||||
task_id,
|
||||
stream,
|
||||
data,
|
||||
} => match stream {
|
||||
StreamType::Stdout => print!("[{}] [{}] [stdout] {}", pipeline_name, task_id, data),
|
||||
StreamType::Stderr => {
|
||||
eprint!("[{}] [{}] [stderr] {}", pipeline_name, task_id, data)
|
||||
}
|
||||
},
|
||||
ExecutionEvent::TaskCompleted { task_id, result } => {
|
||||
log::debug!(
|
||||
"[{}] [{}] Task completed: {} (exit_code={}, duration={:?})",
|
||||
pipeline_name,
|
||||
build_id,
|
||||
task_id,
|
||||
result.exit_code,
|
||||
result.duration
|
||||
);
|
||||
}
|
||||
ExecutionEvent::TaskFailed { task_id, error } => {
|
||||
log::error!(
|
||||
"[{}] [{}] Task failed: {} (error={})",
|
||||
pipeline_name,
|
||||
build_id,
|
||||
task_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
use crate::finalize::RawPluginMap;
|
||||
use crate::finalize::constant::{FINALIZE_PLUGIN_MANIFEST, FINALIZE_PLUGIN_MANIFEST_EXTENSION};
|
||||
use crate::finalize::plugin::metadata::{PluginMetadata, PluginType};
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
use crate::{ finalize::error::PluginError};
|
||||
use crate::types::resource::ResourceRegistry;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
mod dylib;
|
||||
mod internal;
|
||||
mod metadata;
|
||||
mod rhai;
|
||||
mod shell;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FinalizePlugin {
|
||||
pub metadata: PluginMetadata,
|
||||
argument: serde_yaml::Value,
|
||||
pub entry: Arc<dyn AsyncPluginFn>,
|
||||
}
|
||||
|
||||
impl FinalizePlugin {
|
||||
pub fn new(entry: Box<dyn AsyncPluginFn>) -> Self {
|
||||
Self {
|
||||
metadata: PluginMetadata::default(),
|
||||
argument: serde_yaml::Value::Mapping(Default::default()),
|
||||
entry: Arc::from(entry),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_metadata(mut self, metadata: PluginMetadata) -> Self {
|
||||
self.metadata = metadata;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_argument(mut self, argument: serde_yaml::Value) -> Self {
|
||||
self.argument = argument;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn call(
|
||||
&self,
|
||||
runtime_argument: serde_yaml::Value,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
) -> PluginResult {
|
||||
// shallow merge argument
|
||||
let _argument = match (&self.argument, runtime_argument) {
|
||||
(serde_yaml::Value::Mapping(base), serde_yaml::Value::Mapping(runtime)) => {
|
||||
let mut merged = base.clone();
|
||||
for (k, v) in runtime {
|
||||
merged.insert(k.clone(), v.clone());
|
||||
}
|
||||
serde_yaml::Value::Mapping(merged)
|
||||
}
|
||||
_ => self.argument.clone(), // If not both are mappings, just use the original argument
|
||||
};
|
||||
self.entry
|
||||
.call(&self.metadata, _argument, ctx, event_tx)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub type PluginMap = HashMap<String, FinalizePlugin>;
|
||||
type PluginResult = Result<(), PluginError>;
|
||||
|
||||
#[async_trait]
|
||||
pub trait AsyncPluginFn: Send + Sync {
|
||||
async fn call(
|
||||
&self,
|
||||
metadata: &PluginMetadata,
|
||||
argument: serde_yaml::Value,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
) -> PluginResult;
|
||||
}
|
||||
|
||||
/// Reads and parses a plugin's manifest file.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Optional filesystem path to the plugin directory
|
||||
/// * `name` - Name of the plugin (used for error reporting)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns parsed [`PluginMetadata`] on success, or a [`PluginError`] if the manifest
|
||||
/// cannot be read or parsed.
|
||||
fn read_manifest(path: Option<PathBuf>, name: &str) -> Result<PluginMetadata, PluginError> {
|
||||
let path = path.ok_or_else(|| PluginError::PluginUnavailable {
|
||||
name: name.to_string(),
|
||||
reason: "Plugin not present in filesystem.".to_string(),
|
||||
})?;
|
||||
|
||||
// Extract content from manifest.yml or manifest.yaml
|
||||
let manifest_content = 'load: {
|
||||
for ext in FINALIZE_PLUGIN_MANIFEST_EXTENSION {
|
||||
match load_manifest(&path, name, ext) {
|
||||
Ok(content) => break 'load content,
|
||||
Err(PluginError::ManifestNotFound(_)) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
return Err(PluginError::ManifestNotFound(path));
|
||||
};
|
||||
|
||||
log::debug!("[plugin:{}] Parsing manifest content", name);
|
||||
let mut metadata: PluginMetadata =
|
||||
serde_yaml::from_str(manifest_content.as_str()).map_err(|e| {
|
||||
PluginError::PluginUnavailable {
|
||||
name: name.to_string(),
|
||||
reason: format!("Failed to parse plugin manifest: {}", e),
|
||||
}
|
||||
})?;
|
||||
metadata.fspath = path;
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
fn load_manifest(path: &Path, name: &str, suffix: &str) -> Result<String, PluginError> {
|
||||
let manifest_path = path.join(format!("{}{}", FINALIZE_PLUGIN_MANIFEST, suffix));
|
||||
log::debug!(
|
||||
"[plugin:{}] Reading manifest from {}",
|
||||
name,
|
||||
manifest_path.display()
|
||||
);
|
||||
if !manifest_path.exists() {
|
||||
return Err(PluginError::ManifestNotFound(manifest_path));
|
||||
}
|
||||
|
||||
std::fs::read_to_string(&manifest_path).map_err(|e| PluginError::PluginUnavailable {
|
||||
name: name.to_string(),
|
||||
reason: format!(
|
||||
"Failed to read plugin manifest from {}: {}",
|
||||
manifest_path.display(),
|
||||
e
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Determines the plugin type based on the manifest type or file extension.
|
||||
///
|
||||
/// The function first returns `original` if it's not [`PluginType::Unknown`].
|
||||
/// Otherwise, it infers the type from the entrypoint file extension:
|
||||
/// - `.sh` / `.bash` → [`PluginType::Shell`]
|
||||
/// - `.rhai` → [`PluginType::Rhai`]
|
||||
/// - `.so` / `.dll` / `.dylib` → [`PluginType::Dylib`]
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `original` - Plugin type as specified in the manifest
|
||||
/// * `entrypoint` - Path or filename of the plugin entrypoint
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The inferred [`PluginType`].
|
||||
fn get_plugin_type(original: PluginType, entrypoint: &str) -> PluginType {
|
||||
if original != PluginType::Unknown {
|
||||
return original;
|
||||
}
|
||||
let _suffix = entrypoint.rsplit('.').next().unwrap_or("");
|
||||
match _suffix {
|
||||
"sh" | "bash" => PluginType::Shell,
|
||||
"rhai" => PluginType::Rhai,
|
||||
"so" | "dll" | "dylib" => PluginType::Dylib,
|
||||
_ => PluginType::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a plugin entry function instance based on the plugin type.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `plugin_type` - The type of plugin to instantiate
|
||||
/// * `name` - Name of the plugin (used for error reporting)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a boxed [`AsyncPluginFn`] on success, or a [`PluginError`] if the plugin type
|
||||
/// is [`PluginType::Unknown`] and cannot be inferred.
|
||||
fn get_entry(plugin_type: PluginType, name: &str) -> Result<Box<dyn AsyncPluginFn>, PluginError> {
|
||||
match plugin_type {
|
||||
PluginType::Shell => Ok(Box::new(shell::Shell)),
|
||||
PluginType::Rhai => Ok(Box::new(rhai::Rhai)),
|
||||
PluginType::Dylib => Ok(Box::new(dylib::Dylib)),
|
||||
PluginType::Unknown => {
|
||||
log::error!(
|
||||
"Plugin {} has unknown plugin type and cannot be inferred from entrypoint.",
|
||||
name
|
||||
);
|
||||
Err(PluginError::PluginUnavailable {
|
||||
name: name.to_string(),
|
||||
reason: "Unknown plugin type and cannot be inferred from entrypoint".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers all available plugins (internal and external) into a plugin map.
|
||||
///
|
||||
/// Internal plugins are registered first, followed by external plugins loaded from
|
||||
/// the provided [`RawPluginMap`]. Plugins that are unavailable (e.g., missing runtime)
|
||||
/// are logged and skipped.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `external_plugins` - Map of external plugin configurations
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a [`PluginMap`] containing all registered plugins, or a [`PluginError`] if
|
||||
/// a plugin manifest cannot be read or parsed.
|
||||
pub fn register(
|
||||
external_plugins: RawPluginMap,
|
||||
list: Option<&HashSet<String>>,
|
||||
notification_config: &mut crate::finalize::NotificationConfig,
|
||||
registry: &ResourceRegistry,
|
||||
) -> Result<PluginMap, PluginError> {
|
||||
let mut plugins: PluginMap = HashMap::new();
|
||||
let mut count = 0;
|
||||
|
||||
// Register internal plugin
|
||||
for i in internal::get_internal_plugin() {
|
||||
let name = i.name.to_string();
|
||||
match list {
|
||||
Some(list) if !list.contains(&name) => {
|
||||
log::debug!("Plugin {} not in list, skipping", name);
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
log::debug!("Registering internal plugin {}", i.name);
|
||||
plugins.insert(i.name.to_string(), FinalizePlugin::new(i.entry));
|
||||
count += 1;
|
||||
}
|
||||
|
||||
// Register external plugin
|
||||
for (name, data) in external_plugins.into_iter() {
|
||||
match list {
|
||||
Some(list) if !list.contains(&name) => {
|
||||
log::debug!("Plugin {} not in list, skipping", name);
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
log::debug!("Registering external plugin {}", name);
|
||||
let (_source, config) = data.into_iter().next().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Internal Error: Plugin {} has no source configured, this should have been caught by validation!",
|
||||
name
|
||||
)
|
||||
});
|
||||
// Assume every plugin prepared (or definitely unavailable) now
|
||||
if let Some(reason) = config.runtime.error {
|
||||
log::warn!("Plugin {} is not available: {}", name, reason);
|
||||
continue;
|
||||
}
|
||||
// Resolve plugin path from registry instead of config.runtime.fspath
|
||||
let plugin_path = registry.resolve(&name).ok_or_else(|| {
|
||||
PluginError::PluginUnavailable {
|
||||
name: name.clone(),
|
||||
reason: "plugin not found in resource registry".to_string(),
|
||||
}
|
||||
})?;
|
||||
let metadata = read_manifest(Some(plugin_path), &name)?;
|
||||
let plugin_type = get_plugin_type(metadata.plugin_type.clone(), &metadata.entrypoint);
|
||||
let entry = get_entry(plugin_type, &name)?;
|
||||
plugins.insert(
|
||||
name,
|
||||
FinalizePlugin::new(entry)
|
||||
.with_metadata(metadata)
|
||||
.with_argument(config.config),
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
|
||||
// Inject plugin-declared templates as .fallback
|
||||
for (name, plugin) in &plugins {
|
||||
if let Some(ref templates) = plugin.metadata.templates {
|
||||
let key = format!("{}.fallback", name);
|
||||
for tmpl in templates {
|
||||
notification_config.templates.insert(key.clone(), tmpl.clone());
|
||||
}
|
||||
log::debug!("Registered templates from plugin '{}' as '{}'", name, key);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Registered {} plugins", count);
|
||||
Ok(plugins)
|
||||
}
|
||||
|
||||
/// Calls a registered plugin by name, passing the configuration, execution context and event sender.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `plugins` - Map of registered plugin names to their [`FinalizePlugin`] instances
|
||||
/// * `name` - Name of the plugin to invoke
|
||||
/// * `config` - Configuration value passed to the plugin
|
||||
/// * `ctx` - Execution context for the plugin
|
||||
/// * `event_tx` - Event sender for plugin lifecycle events
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Ok(())` on successful plugin execution, or a [`PluginError`] if the plugin
|
||||
/// is not found or execution fails.
|
||||
pub async fn call_named_plugin(
|
||||
plugins: &PluginMap,
|
||||
name: &str,
|
||||
config: serde_yaml::Value,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
) -> PluginResult {
|
||||
let plugin = plugins
|
||||
.get(name)
|
||||
.ok_or_else(|| PluginError::UnknownPlugin(name.to_string()))?;
|
||||
plugin.call(config, ctx, event_tx).await
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
use crate::{ finalize::plugin::AsyncPluginFn};
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub struct Dylib;
|
||||
|
||||
#[async_trait]
|
||||
impl AsyncPluginFn for Dylib {
|
||||
async fn call(
|
||||
&self,
|
||||
_metadata: &PluginMetadata,
|
||||
_argument: serde_yaml::Value,
|
||||
_ctx: &ExecutionContext,
|
||||
_event_tx: &EventSender,
|
||||
) -> Result<(), PluginError> {
|
||||
unimplemented!("Dylib plugin is not implemented yet");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use crate::finalize::plugin::AsyncPluginFn;
|
||||
pub mod dummy;
|
||||
mod insitenotify;
|
||||
mod mail;
|
||||
mod satori;
|
||||
mod webhook;
|
||||
pub struct InternalPlugin {
|
||||
pub name: &'static str,
|
||||
pub entry: Box<dyn AsyncPluginFn>,
|
||||
}
|
||||
|
||||
pub fn get_internal_plugin() -> Vec<InternalPlugin> {
|
||||
vec![
|
||||
InternalPlugin {
|
||||
name: "in-site-notify",
|
||||
entry: Box::new(insitenotify::InsiteNotify),
|
||||
},
|
||||
InternalPlugin {
|
||||
name: "mail",
|
||||
entry: Box::new(mail::Mail),
|
||||
},
|
||||
InternalPlugin {
|
||||
name: "satori",
|
||||
entry: Box::new(satori::Satori),
|
||||
},
|
||||
InternalPlugin {
|
||||
name: "webhook",
|
||||
entry: Box::new(webhook::Webhook),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn get_dummy_plugin() -> Vec<InternalPlugin> {
|
||||
vec![InternalPlugin {
|
||||
name: "dummy",
|
||||
entry: Box::new(dummy::Dummy),
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
use crate::{ finalize::plugin::AsyncPluginFn};
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub struct Dummy;
|
||||
|
||||
#[async_trait]
|
||||
impl AsyncPluginFn for Dummy {
|
||||
async fn call(
|
||||
&self,
|
||||
_metadata: &PluginMetadata,
|
||||
_argument: serde_yaml::Value,
|
||||
_ctx: &ExecutionContext,
|
||||
_event_tx: &EventSender,
|
||||
) -> Result<(), PluginError> {
|
||||
log::debug!("[NOTIFY_DEBUG] Successfully registered dummy plugin.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use crate::{
|
||||
finalize::{error::PluginError, plugin::AsyncPluginFn},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
pub struct InsiteNotify;
|
||||
#[async_trait]
|
||||
impl AsyncPluginFn for InsiteNotify {
|
||||
async fn call(
|
||||
&self,
|
||||
_metadata: &PluginMetadata,
|
||||
_argument: serde_yaml::Value,
|
||||
_ctx: &ExecutionContext,
|
||||
_event_tx: &EventSender,
|
||||
) -> Result<(), PluginError> {
|
||||
log::info!("in-site-notify: placeholder (always succeeds)");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
notification:
|
||||
1:
|
||||
# 最简配置
|
||||
mail: # 插件名,不可变动,下同
|
||||
to: "admin@example.com"
|
||||
from: "ci@example.com"
|
||||
subject: "Build {{ build.status }}: {{ pipeline.name }}"
|
||||
body: "Pipeline {{ pipeline.name }} build #{{ build.id }} finished with status: {{ build.status }}"
|
||||
smtp:
|
||||
host: "smtp.gmail.com"
|
||||
port: 587
|
||||
username: "{{ secret.smtp_user }}"
|
||||
password: "{{ secret.smtp_pass }}"
|
||||
|
||||
# 高级配置(完整功能)
|
||||
mail:
|
||||
# ===== 收件人配置 =====
|
||||
to:
|
||||
- "team@example.com"
|
||||
- "admin@example.com"
|
||||
- "{{ commit.author_email }}" # 模板变量支持
|
||||
|
||||
cc:
|
||||
- "manager@example.com"
|
||||
|
||||
bcc:
|
||||
- "archive@example.com"
|
||||
|
||||
# 多态,单字符串=email
|
||||
from:
|
||||
name: "HoneyBiscuitWorkshop CI"
|
||||
email: "ci@example.com"
|
||||
reply_to: "support@example.com"
|
||||
|
||||
# 可选:使用预定义模板
|
||||
schema: "custom" # 或 "default",或定义在notification.schema里的字段
|
||||
|
||||
# ===== 主题配置,schema=custom时可用 =====
|
||||
subject: "[{{ build.status | upper }}] {{ pipeline.name }} #{{ build.id }} - {{ commit.message | truncate(50) }}"
|
||||
|
||||
# ===== 正文配置,schema=custom时可用 =====
|
||||
body: |
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif;">
|
||||
<h2 style="color: {{ build.status_color }};">
|
||||
Build {{ build.status | title }}
|
||||
</h2>
|
||||
<table>
|
||||
<tr><td><strong>Pipeline:</strong></td><td>{{ pipeline.name }}</td></tr>
|
||||
<tr><td><strong>Build ID:</strong></td><td>{{ build.id }}</td></tr>
|
||||
<tr><td><strong>Status:</strong></td><td>{{ build.status }}</td></tr>
|
||||
<tr><td><strong>Duration:</strong></td><td>{{ build.duration }}</td></tr>
|
||||
<tr><td><strong>Commit:</strong></td><td>{{ commit.hash | slice(0, 7) }}</td></tr>
|
||||
<tr><td><strong>Author:</strong></td><td>{{ commit.author }}</td></tr>
|
||||
<tr><td><strong>Message:</strong></td><td>{{ commit.message }}</td></tr>
|
||||
</table>
|
||||
<p>
|
||||
<a href="{{ build.url }}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
|
||||
View Build Details
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
# ===== SMTP 配置 =====
|
||||
smtp:
|
||||
host: "smtp.gmail.com"
|
||||
port: 587
|
||||
|
||||
# 认证方式:password / oauth2 / none
|
||||
auth:
|
||||
type: "password"
|
||||
username: "{{ secret.smtp_user }}"
|
||||
password: "{{ secret.smtp_pass }}"
|
||||
|
||||
# 加密方式:tls / starttls / none
|
||||
encryption: "starttls"
|
||||
|
||||
# 连接超时
|
||||
connect_timeout: "30s"
|
||||
|
||||
timeout: "90s"
|
||||
retry: 3
|
||||
fallible: false
|
||||
|
||||
|
||||
# 仅作参考,暂不实现:
|
||||
# OAuth2 配置示例(Gmail)
|
||||
mail:
|
||||
to: "user@example.com"
|
||||
from: "ci@example.com"
|
||||
subject: "Build Notification"
|
||||
body: "Build completed"
|
||||
schema: "default"
|
||||
smtp:
|
||||
host: "smtp.gmail.com"
|
||||
port: 465
|
||||
auth:
|
||||
type: "oauth2"
|
||||
client_id: "{{ secret.gmail_client_id }}"
|
||||
client_secret: "{{ secret.gmail_client_secret }}"
|
||||
refresh_token: "{{ secret.gmail_refresh_token }}"
|
||||
encryption: "tls"
|
||||
|
||||
# 企业级配置(Exchange/Office365)
|
||||
mail:
|
||||
to: "team@company.com"
|
||||
from: "ci@company.com"
|
||||
subject: "{{ pipeline.name }} Build {{ build.status }}"
|
||||
schema: "default"
|
||||
smtp:
|
||||
host: "smtp.office365.com"
|
||||
port: 587
|
||||
auth:
|
||||
type: "password"
|
||||
username: "{{ secret.exchange_user }}"
|
||||
password: "{{ secret.exchange_pass }}"
|
||||
encryption: "starttls"
|
||||
# 企业环境可能需要自定义 TLS 配置
|
||||
tls:
|
||||
verify: true
|
||||
ca_cert: "{{ secret.exchange_cert }}" # 可选
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use crate::{
|
||||
finalize::{error::PluginError, plugin::AsyncPluginFn},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
|
||||
pub struct Mail;
|
||||
|
||||
#[async_trait]
|
||||
impl AsyncPluginFn for Mail {
|
||||
async fn call(
|
||||
&self,
|
||||
_metadata: &PluginMetadata,
|
||||
_argument: serde_yaml::Value,
|
||||
_ctx: &ExecutionContext,
|
||||
_event_tx: &EventSender,
|
||||
) -> Result<(), PluginError> {
|
||||
todo!();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
use crate::{ finalize::plugin::AsyncPluginFn};
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub struct Satori;
|
||||
|
||||
#[async_trait]
|
||||
impl AsyncPluginFn for Satori {
|
||||
async fn call(
|
||||
&self,
|
||||
_metadata: &PluginMetadata,
|
||||
_argument: serde_yaml::Value,
|
||||
_ctx: &ExecutionContext,
|
||||
_event_tx: &EventSender,
|
||||
) -> Result<(), PluginError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
use crate::{ finalize::plugin::AsyncPluginFn};
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub struct Webhook;
|
||||
|
||||
#[async_trait]
|
||||
impl AsyncPluginFn for Webhook {
|
||||
async fn call(
|
||||
&self,
|
||||
_metadata: &PluginMetadata,
|
||||
_argument: serde_yaml::Value,
|
||||
_ctx: &ExecutionContext,
|
||||
_event_tx: &EventSender,
|
||||
) -> Result<(), PluginError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use crate::finalize::NotificationTemplateDef;
|
||||
use crate::notify::types::NotificationRenderer;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Plugin manifest metadata containing identification and configuration.
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct PluginMetadata {
|
||||
/// Plugin name identifier.
|
||||
pub name: String,
|
||||
/// Plugin version string.
|
||||
pub version: String,
|
||||
/// Path or command to invoke the plugin.
|
||||
pub entrypoint: String,
|
||||
/// Plugin type classification.
|
||||
#[serde(default)]
|
||||
pub plugin_type: PluginType,
|
||||
/// (Notification) Renderer for plugin output.
|
||||
#[serde(default)]
|
||||
pub renderer: Option<NotificationRenderer>,
|
||||
|
||||
/// Additional YAML fields.
|
||||
#[serde(flatten)]
|
||||
pub value: serde_yaml::Value,
|
||||
|
||||
/// Plugin-defined default templates, auto-registered as <name>.fallback
|
||||
#[serde(default)]
|
||||
pub templates: Option<Vec<NotificationTemplateDef>>,
|
||||
|
||||
/// Filesystem path to plugin definition.
|
||||
#[serde(skip)]
|
||||
pub fspath: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for PluginMetadata {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: String::new(),
|
||||
version: String::new(),
|
||||
entrypoint: String::new(),
|
||||
plugin_type: PluginType::Unknown,
|
||||
value: serde_yaml::Value::Null,
|
||||
renderer: None,
|
||||
templates: None,
|
||||
fspath: PathBuf::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin execution model.
|
||||
#[derive(Deserialize, Default, PartialEq, Eq, Clone, Debug)]
|
||||
pub enum PluginType {
|
||||
#[default]
|
||||
Unknown,
|
||||
Shell,
|
||||
Rhai,
|
||||
Dylib,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
use crate::{ finalize::plugin::AsyncPluginFn};
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub struct Rhai;
|
||||
|
||||
#[async_trait]
|
||||
impl AsyncPluginFn for Rhai {
|
||||
async fn call(
|
||||
&self,
|
||||
_metadata: &PluginMetadata,
|
||||
_argument: serde_yaml::Value,
|
||||
_ctx: &ExecutionContext,
|
||||
_event_tx: &EventSender,
|
||||
) -> Result<(), PluginError> {
|
||||
todo!("Rhai plugin is not implemented yet");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
use workshop_engine::Engine;
|
||||
use crate::finalize::constant::FINALIZE_SHELL_ENVPREFIX;
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
use crate::{ finalize::plugin::AsyncPluginFn};
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct Shell;
|
||||
|
||||
/// Parses a YAML value used as a mapping key into a string.
|
||||
///
|
||||
/// Since YAML keys can be strings, numbers, or booleans, this function
|
||||
/// normalizes them into a consistent [`String`] representation.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - A YAML value representing a mapping key
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A string representation of the key. Returns `"?"` for unsupported types
|
||||
/// (such as mappings or sequences) while logging a warning.
|
||||
///
|
||||
/// # See Also
|
||||
///
|
||||
/// [`convert_argument`] — which calls this function for key parsing
|
||||
// Workaround flexible key in yaml
|
||||
fn parse_key(key: serde_yaml::Value) -> String {
|
||||
match key {
|
||||
serde_yaml::Value::String(s) => s,
|
||||
serde_yaml::Value::Number(n) => n.to_string(),
|
||||
serde_yaml::Value::Bool(b) => b.to_string(),
|
||||
_ => {
|
||||
log::warn!("Unsupported key type in mapping: {:?}", key);
|
||||
String::from("?")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a YAML argument value into shell environment variable key-value pairs.
|
||||
///
|
||||
/// This function recursively traverses YAML structures and flattens them into
|
||||
/// environment variables suitable for shell script consumption:
|
||||
///
|
||||
/// - **Mapping** → keys joined with `_` (e.g., `{a: {b: 1}}` becomes `PREFIX_A_B=1`)
|
||||
/// - **Sequence** → shell array syntax (e.g., `["x", "y"]` becomes `PREFIX=(x y)`)
|
||||
/// - **Scalar values** → direct assignment with type-appropriate formatting
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `prefix` - The environment variable key prefix (accumulates via recursion)
|
||||
/// * `argument` - The YAML value to convert
|
||||
/// * `in_sequence` - Whether currently processing a sequence item (prevents nested arrays)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A [`HashMap`] of environment variable names to their string values.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// - Keys are uppercased during conversion for shell compatibility
|
||||
/// - Strings are double-quoted to preserve special characters
|
||||
/// - Null values are skipped (not emitted as empty strings)
|
||||
/// - Nested sequences within sequences are not supported and are skipped
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let yaml = serde_yaml::from_str("host: docker.io\nuser: admin").unwrap();
|
||||
/// let result = convert_argument("PLUGIN", yaml, false);
|
||||
/// // Yields: {"PLUGIN_HOST" => "\"docker.io\"", "PLUGIN_USER" => "\"admin\""}
|
||||
/// ```
|
||||
fn convert_argument(
|
||||
prefix: &str,
|
||||
argument: serde_yaml::Value,
|
||||
in_sequence: bool,
|
||||
) -> HashMap<String, String> {
|
||||
let mut envvars: HashMap<String, String> = HashMap::new();
|
||||
match argument {
|
||||
serde_yaml::Value::Mapping(mapping) => {
|
||||
for i in mapping {
|
||||
let key = parse_key(i.0).to_uppercase();
|
||||
let value = i.1.clone();
|
||||
let new_prefix = format!("{}_{}", prefix, key);
|
||||
let subenvvars = convert_argument(new_prefix.as_str(), value, false);
|
||||
envvars.extend(subenvvars);
|
||||
}
|
||||
}
|
||||
serde_yaml::Value::Null => {
|
||||
// NOTE: Null value should be eliminated.
|
||||
}
|
||||
serde_yaml::Value::Bool(value) => {
|
||||
envvars.insert(prefix.to_string(), value.to_string());
|
||||
}
|
||||
serde_yaml::Value::String(value) => {
|
||||
envvars.insert(prefix.to_string(), value.to_string());
|
||||
}
|
||||
serde_yaml::Value::Number(value) => {
|
||||
envvars.insert(prefix.to_string(), value.to_string());
|
||||
}
|
||||
serde_yaml::Value::Tagged(tagged_value) => {
|
||||
let subenvvars = convert_argument(prefix, tagged_value.value, in_sequence);
|
||||
envvars.extend(subenvvars);
|
||||
}
|
||||
serde_yaml::Value::Sequence(seq) => {
|
||||
if in_sequence {
|
||||
log::warn!(
|
||||
"Nested sequence in sequence is not supported, skipping: {}",
|
||||
prefix
|
||||
);
|
||||
return envvars;
|
||||
}
|
||||
let mut array = String::new();
|
||||
array.push('(');
|
||||
let mut has_mapping = false;
|
||||
let mut _envvars: HashMap<String, String> = HashMap::new();
|
||||
for (index, item) in seq.into_iter().enumerate() {
|
||||
if index > 0 {
|
||||
array.push(' ');
|
||||
}
|
||||
let new_prefix = format!("{}_{}", prefix, index);
|
||||
let subenvvars = convert_argument(new_prefix.as_str(), item.clone(), true);
|
||||
if item.is_mapping() {
|
||||
has_mapping = true;
|
||||
} else {
|
||||
array.push_str(
|
||||
subenvvars
|
||||
.get(new_prefix.as_str())
|
||||
.map(|v| v.as_str())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
_envvars.extend(subenvvars);
|
||||
}
|
||||
if has_mapping {
|
||||
envvars.extend(_envvars);
|
||||
} else {
|
||||
array.push(')');
|
||||
envvars.insert(prefix.to_string(), array);
|
||||
}
|
||||
}
|
||||
}
|
||||
envvars
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AsyncPluginFn for Shell {
|
||||
async fn call(
|
||||
&self,
|
||||
metadata: &PluginMetadata,
|
||||
argument: serde_yaml::Value,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
) -> Result<(), PluginError> {
|
||||
let engine = Engine::new();
|
||||
let envvars = convert_argument(FINALIZE_SHELL_ENVPREFIX, argument, false);
|
||||
let mut local_ctx = ctx.clone();
|
||||
local_ctx.env_vars.extend(envvars);
|
||||
engine
|
||||
.execute_script(
|
||||
&metadata.fspath.join(&metadata.entrypoint),
|
||||
&local_ctx,
|
||||
event_tx,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_string() {
|
||||
let key = serde_yaml::Value::String("test_key".to_string());
|
||||
assert_eq!(parse_key(key), "test_key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_number() {
|
||||
let key = serde_yaml::Value::Number(42.into());
|
||||
assert_eq!(parse_key(key), "42");
|
||||
|
||||
let key = serde_yaml::Value::Number(3.14.into());
|
||||
assert_eq!(parse_key(key), "3.14");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_bool() {
|
||||
let key = serde_yaml::Value::Bool(true);
|
||||
assert_eq!(parse_key(key), "true");
|
||||
|
||||
let key = serde_yaml::Value::Bool(false);
|
||||
assert_eq!(parse_key(key), "false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_unsupported() {
|
||||
let key = serde_yaml::Value::Null;
|
||||
assert_eq!(parse_key(key), "?");
|
||||
|
||||
let key = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
|
||||
assert_eq!(parse_key(key), "?");
|
||||
|
||||
let key = serde_yaml::Value::Sequence(vec![]);
|
||||
assert_eq!(parse_key(key), "?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_argument_simple_mapping() {
|
||||
let yaml = serde_yaml::from_str("host: docker.io\nuser: admin").unwrap();
|
||||
let result = convert_argument("PLUGIN", yaml, false);
|
||||
|
||||
// Keys are uppercased in actual call() function
|
||||
assert_eq!(result.get("PLUGIN_HOST"), Some(&"docker.io".to_string()));
|
||||
assert_eq!(result.get("PLUGIN_USER"), Some(&"admin".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_argument_nested_mapping() {
|
||||
let yaml = serde_yaml::from_str(
|
||||
"
|
||||
from:
|
||||
host: smtp.example.com
|
||||
port: 587
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let result = convert_argument("CFG", yaml, false);
|
||||
|
||||
// Nested keys use uppercase format: CFG_FROM_HOST
|
||||
assert_eq!(
|
||||
result.get("CFG_FROM_HOST"),
|
||||
Some(&"smtp.example.com".to_string())
|
||||
);
|
||||
assert_eq!(result.get("CFG_FROM_PORT"), Some(&"587".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_argument_different_types() {
|
||||
let yaml = serde_yaml::from_str(
|
||||
"
|
||||
enabled: true
|
||||
count: 42
|
||||
ratio: 3.14
|
||||
name: test
|
||||
empty: ~
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let result = convert_argument("VAR", yaml, false);
|
||||
|
||||
assert_eq!(result.get("VAR_ENABLED"), Some(&"true".to_string()));
|
||||
assert_eq!(result.get("VAR_COUNT"), Some(&"42".to_string()));
|
||||
assert_eq!(result.get("VAR_RATIO"), Some(&"3.14".to_string()));
|
||||
assert_eq!(result.get("VAR_NAME"), Some(&"test".to_string()));
|
||||
assert!(!result.contains_key("VAR_EMPTY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_argument_sequence_simple() {
|
||||
let yaml = serde_yaml::from_str("tags:\n - latest\n - v1.0\n - stable").unwrap();
|
||||
let result = convert_argument("CFG", yaml, false);
|
||||
|
||||
assert_eq!(
|
||||
result.get("CFG_TAGS"),
|
||||
Some(&"(latest v1.0 stable)".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_argument_sequence_with_mapping() {
|
||||
let yaml = serde_yaml::from_str(
|
||||
"
|
||||
env:
|
||||
- name: KEY1
|
||||
value: val1
|
||||
- name: KEY2
|
||||
value: val2
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let result = convert_argument("CFG", yaml, false);
|
||||
|
||||
assert_eq!(result.get("CFG_ENV_0_NAME"), Some(&"KEY1".to_string()));
|
||||
assert_eq!(result.get("CFG_ENV_0_VALUE"), Some(&"val1".to_string()));
|
||||
assert_eq!(result.get("CFG_ENV_1_NAME"), Some(&"KEY2".to_string()));
|
||||
assert_eq!(result.get("CFG_ENV_1_VALUE"), Some(&"val2".to_string()));
|
||||
assert!(!result.contains_key("CFG_ENV"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_argument_nested_sequence() {
|
||||
let yaml = serde_yaml::from_str(
|
||||
"
|
||||
matrix:
|
||||
- - a
|
||||
- b
|
||||
- - c
|
||||
- d
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let result = convert_argument("CFG", yaml, false);
|
||||
|
||||
// Nested sequences are not supported (line 50-52 logs warning and skips)
|
||||
// So CFG_MATRIX_0 and CFG_MATRIX_1 keys are not created
|
||||
assert!(!result.contains_key("CFG_MATRIX_0"));
|
||||
assert!(!result.contains_key("CFG_MATRIX_1"));
|
||||
// The outer array has empty slots where nested sequences were skipped
|
||||
// (space separator is still added even for empty values)
|
||||
assert_eq!(result.get("CFG_MATRIX"), Some(&"( )".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_argument_empty_mapping() {
|
||||
let yaml = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
|
||||
let result = convert_argument("CFG", yaml, false);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_argument_empty_sequence() {
|
||||
let yaml = serde_yaml::Value::Sequence(vec![]);
|
||||
let result = convert_argument("CFG", yaml, false);
|
||||
assert_eq!(result.get("CFG"), Some(&"()".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_argument_null_value() {
|
||||
let yaml = serde_yaml::from_str(
|
||||
"
|
||||
host: docker.io
|
||||
token: ~
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let result = convert_argument("CFG", yaml, false);
|
||||
|
||||
// Keys are uppercased: CFG_HOST
|
||||
assert_eq!(result.get("CFG_HOST"), Some(&"docker.io".to_string()));
|
||||
// Null value is skipped (not set)
|
||||
assert!(!result.contains_key("CFG_TOKEN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_argument_number_keys() {
|
||||
let yaml = serde_yaml::from_str(
|
||||
"
|
||||
0: first
|
||||
1: second
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let result = convert_argument("CFG", yaml, false);
|
||||
|
||||
// Number keys are preserved but prefixed: CFG_0, CFG_1
|
||||
assert_eq!(result.get("CFG_0"), Some(&"first".to_string()));
|
||||
assert_eq!(result.get("CFG_1"), Some(&"second".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_argument_complex_nested() {
|
||||
let yaml = serde_yaml::from_str(
|
||||
"
|
||||
plugin:
|
||||
docker-push:
|
||||
host: docker.io
|
||||
image: user/app:tag
|
||||
options:
|
||||
force: true
|
||||
retries: 3
|
||||
tags:
|
||||
- latest
|
||||
- v1.0
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let result = convert_argument("CFG", yaml, false);
|
||||
|
||||
// All keys uppercased for shell environment variables
|
||||
assert_eq!(
|
||||
result.get("CFG_PLUGIN_DOCKER-PUSH_HOST"),
|
||||
Some(&"docker.io".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
result.get("CFG_PLUGIN_DOCKER-PUSH_IMAGE"),
|
||||
Some(&"user/app:tag".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
result.get("CFG_PLUGIN_DOCKER-PUSH_OPTIONS_FORCE"),
|
||||
Some(&"true".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
result.get("CFG_PLUGIN_DOCKER-PUSH_OPTIONS_RETRIES"),
|
||||
Some(&"3".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
result.get("CFG_PLUGIN_DOCKER-PUSH_TAGS"),
|
||||
Some(&"(latest v1.0)".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_argument_special_chars_in_values() {
|
||||
let yaml = serde_yaml::from_str(
|
||||
"
|
||||
url: https://example.com/path?query=value
|
||||
path: /workspace/output.tar.gz
|
||||
token: 'Bearer abc123'
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let result = convert_argument("CFG", yaml, false);
|
||||
|
||||
// Keys uppercased: CFG_URL, CFG_PATH, CFG_TOKEN
|
||||
assert_eq!(
|
||||
result.get("CFG_URL"),
|
||||
Some(&"https://example.com/path?query=value".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
result.get("CFG_PATH"),
|
||||
Some(&"/workspace/output.tar.gz".to_string())
|
||||
);
|
||||
assert_eq!(result.get("CFG_TOKEN"), Some(&"Bearer abc123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_argument_quoted_strings_preserved() {
|
||||
let yaml = serde_yaml::from_str(
|
||||
"
|
||||
msg: 'Hello World'
|
||||
cmd: \"echo 'test'\"
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let result = convert_argument("CFG", yaml, false);
|
||||
|
||||
// Keys uppercased: CFG_MSG, CFG_CMD
|
||||
assert_eq!(result.get("CFG_MSG"), Some(&"Hello World".to_string()));
|
||||
assert_eq!(result.get("CFG_CMD"), Some(&"echo 'test'".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod hook;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum FinalizeStage {
|
||||
Register,
|
||||
EarlyHook,
|
||||
Artifact,
|
||||
Publish,
|
||||
Notify,
|
||||
LateHook,
|
||||
Clean,
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use workshop_engine::ExecutionContext;
|
||||
use crate::finalize::plugin::{PluginMap, call_named_plugin};
|
||||
use std::path::PathBuf;
|
||||
use workshop_engine::{CustomCommand, DeltaExecutionContext, Engine, EventSender, ExecutionError};
|
||||
|
||||
/// Executes post-build finalize hooks, supporting both plugin and shell command hooks.
|
||||
///
|
||||
/// Each hook can be a plugin call (prefixed with "plugin:") or a direct shell command.
|
||||
///
|
||||
/// Plugins receive a modified context with custom timeout and environment variables.
|
||||
pub async fn hook(
|
||||
hook: Option<&Vec<CustomCommand>>,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: EventSender,
|
||||
plugins: &PluginMap,
|
||||
) -> Result<(), ExecutionError> {
|
||||
let hook = match hook {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let engine = Engine::new();
|
||||
for (index, hook_cmd) in hook.iter().enumerate() {
|
||||
if hook_cmd.command.starts_with("plugin:") {
|
||||
let mut plugin_ctx = ctx.clone();
|
||||
plugin_ctx.timeout = Some(std::time::Duration::from_millis(hook_cmd.timeout_ms));
|
||||
for i in hook_cmd.environment.clone().unwrap_or_default() {
|
||||
match i.1 {
|
||||
Some(v) => {
|
||||
plugin_ctx.env_vars.insert(i.0, v);
|
||||
}
|
||||
None => {
|
||||
plugin_ctx.env_vars.remove(&i.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let plugin_name = hook_cmd.command.strip_prefix("plugin:").expect(
|
||||
"Internal error: command starts with \"plugin:\" but failed to strip prefix",
|
||||
);
|
||||
log::info!("Calling plugin hook {}: {}", index, plugin_name);
|
||||
call_named_plugin(
|
||||
plugins,
|
||||
plugin_name,
|
||||
hook_cmd.argument.clone(),
|
||||
&plugin_ctx,
|
||||
&event_tx,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Plugin hook {} failed: {:?}", index, e);
|
||||
ExecutionError::ExecutionFailed(e.to_string())
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
let deltactx = DeltaExecutionContext {
|
||||
working_dir: hook_cmd.working_dir.as_ref().map(PathBuf::from),
|
||||
env_vars: hook_cmd.environment.clone().unwrap_or_default(),
|
||||
timeout: Some(std::time::Duration::from_millis(hook_cmd.timeout_ms)),
|
||||
};
|
||||
log::info!("Executing hook #{}: {}.", index, hook_cmd.name);
|
||||
let result = engine
|
||||
.execute_command_with_delta(&hook_cmd.command, ctx, &event_tx, &deltactx)
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
log::error!("Hook {} failed: {:?}", index, e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// WIP: Template resolution will be implemented as a shared module
|
||||
// used by both prebake and finalize stages.
|
||||
// See: workshop-baker/src/template/ (planned)
|
||||
//
|
||||
// Variable namespaces:
|
||||
// {{ xxx }} - User config values (nested: dockerhub.user)
|
||||
// {{ secret.xxx }} - Vault secrets
|
||||
// {{ build.status }} - Build metadata
|
||||
// {{ build.duration }} - Build duration in seconds
|
||||
// {{ build.stage }} - Current/last stage
|
||||
// {{ env.xxx }} - Environment variables
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user