Compare commits
3 Commits
9e45bdbbfb
...
a64e47d772
| Author | SHA1 | Date | |
|---|---|---|---|
| a64e47d772 | |||
| 8fd807f174 | |||
| e8ce2eec9e |
+20
-14
@@ -5,27 +5,33 @@ on:
|
||||
branches: [ master, main ]
|
||||
paths:
|
||||
- 'workshop-baker/**'
|
||||
- 'workshop-engine/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
pull_request:
|
||||
branches: [ master, main ]
|
||||
paths:
|
||||
- 'workshop-baker/**'
|
||||
- 'workshop-engine/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
check-and-lint:
|
||||
name: Check & Lint
|
||||
ci:
|
||||
name: ${{ matrix.crate }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
crate: [workshop-baker, workshop-engine]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./workshop-baker
|
||||
working-directory: ./${{ matrix.crate }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -38,19 +44,19 @@ jobs:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
${{ matrix.crate }}/target
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.crate }}-${{ hashFiles(format('{0}/Cargo.lock', matrix.crate)) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
${{ runner.os }}-cargo-${{ matrix.crate }}-
|
||||
|
||||
- name: Check compilation
|
||||
run: cargo check --all-features
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --all-features
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt -- --check
|
||||
|
||||
- name: Check code (cargo check)
|
||||
run: cargo check --all-features
|
||||
|
||||
- name: Run Clippy lints
|
||||
run: cargo clippy --all-features -- -D warnings
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --all-features
|
||||
|
||||
@@ -45,6 +45,7 @@ HoneyBiscuitWorkshop is a Rust-based CI/CD system ("蜜饼工坊") with LLM-powe
|
||||
|
||||
### Rust
|
||||
- **Edition**: 2024 (non-standard, requires Rust 1.85+)
|
||||
- Deprecate `mod.rs`
|
||||
- **Naming**: `snake_case` files/modules, `PascalCase` types, `SCREAMING_SNAKE_CASE` consts
|
||||
- **Error handling**: `thiserror` + `anyhow` combo
|
||||
- **Async**: `#[tokio::main]` + `tokio` with "full" features
|
||||
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
diff --git a/workshop-baker/src/notify.rs b/workshop-baker/src/notify.rs
|
||||
index 75bce82..ad56c66 100644
|
||||
--- a/workshop-baker/src/notify.rs
|
||||
+++ b/workshop-baker/src/notify.rs
|
||||
@@ -3,15 +3,15 @@ use crate::finalize::FinalizeConfig;
|
||||
use crate::finalize::parse;
|
||||
use crate::finalize::plugin;
|
||||
use crate::finalize::plugin::fetch;
|
||||
-use crate::notify::types::BundledNotifyEvent;
|
||||
+use crate::notify::types::BundledNotificationEvent;
|
||||
use crate::notify::types::NOTIFY_TEMPORARY_DELAY_FACTOR;
|
||||
use crate::notify::types::NOTIFY_TEMPORARY_DELAY_TIME;
|
||||
use crate::notify::types::NOTIFY_TEMPORARY_MAX_TIME;
|
||||
use crate::notify::types::NotificationRulesMap;
|
||||
-use crate::notify::types::NotifyEvent;
|
||||
-use crate::notify::types::NotifyEventReceiver;
|
||||
-use crate::notify::types::NotifyEventSender;
|
||||
-use crate::notify::types::NotifyPluginMap;
|
||||
+use crate::notify::types::NotificationEvent;
|
||||
+use crate::notify::types::NotificationEventReceiver;
|
||||
+use crate::notify::types::NotificationEventSender;
|
||||
+use crate::notify::types::NotificationPluginMap;
|
||||
use crate::{EventSender, ExecutionContext};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
@@ -46,8 +46,8 @@ pub async fn notify(
|
||||
finalize_path: &Path,
|
||||
ctx: &mut ExecutionContext,
|
||||
event_tx: EventSender,
|
||||
- notifyevent_rx: &mut NotifyEventReceiver,
|
||||
- notifyevent_tx: NotifyEventSender,
|
||||
+ notifyevent_rx: &mut NotificationEventReceiver,
|
||||
+ notifyevent_tx: NotificationEventSender,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut finalize = parse(finalize_path)?;
|
||||
validate(&finalize)?;
|
||||
@@ -114,10 +114,11 @@ pub async fn notify(
|
||||
}
|
||||
|
||||
fn notification_handler(
|
||||
- plugins: &NotifyPluginMap,
|
||||
+ plugins: &NotificationPluginMap,
|
||||
config: &NotificationRulesMap,
|
||||
- event: &BundledNotifyEvent,
|
||||
+ event: &BundledNotificationEvent,
|
||||
) -> Result<(), NotifyError> {
|
||||
+
|
||||
todo!();
|
||||
}
|
||||
|
||||
@@ -132,7 +133,7 @@ fn wait_time(group_period: u32, group_watermark: u32, count: u32) -> f32 {
|
||||
}
|
||||
|
||||
// calculate p^(m-l)*t as notification delay
|
||||
-fn notify_delay(event: &NotifyEvent) -> std::time::Duration {
|
||||
+fn notify_delay(event: &NotificationEvent) -> std::time::Duration {
|
||||
let exponent = (event.max_lives - event.lives) as i32;
|
||||
if exponent < 0 {
|
||||
log::warn!(
|
||||
diff --git a/workshop-baker/src/notify/types.rs b/workshop-baker/src/notify/types.rs
|
||||
index b852dfb..d6ccfb7 100644
|
||||
--- a/workshop-baker/src/notify/types.rs
|
||||
+++ b/workshop-baker/src/notify/types.rs
|
||||
@@ -39,13 +39,13 @@ pub struct NotificationRule{
|
||||
pub type Priority = u32;
|
||||
pub type Method = String;
|
||||
pub type NotificationRulesMap = HashMap<Priority, HashMap<Method, Vec<NotificationRule>>>;
|
||||
-pub type BundledNotifyEvent = Vec<NotifyEvent>;
|
||||
-pub type NotifyEventReceiver = tokio::sync::mpsc::Receiver<BundledNotifyEvent>;
|
||||
-pub type NotifyEventSender = tokio::sync::mpsc::Sender<NotifyEvent>;
|
||||
-pub type NotifyPluginMap = HashMap<String, FinalizePlugin>;
|
||||
+pub type BundledNotificationEvent = Vec<NotificationEvent>;
|
||||
+pub type NotificationEventReceiver = tokio::sync::mpsc::Receiver<BundledNotificationEvent>;
|
||||
+pub type NotificationEventSender = tokio::sync::mpsc::Sender<NotificationEvent>;
|
||||
+pub type NotificationPluginMap = HashMap<String, FinalizePlugin>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
-pub struct NotifyEvent {
|
||||
+pub struct NotificationEvent {
|
||||
pub id: Uuid,
|
||||
pub stage: StagePhase,
|
||||
pub substage: String,
|
||||
@@ -58,21 +58,21 @@ pub struct NotifyEvent {
|
||||
pub target: HashSet<String>, // Target audience
|
||||
}
|
||||
|
||||
-impl Hash for NotifyEvent {
|
||||
+impl Hash for NotificationEvent {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.id.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
-impl PartialEq for NotifyEvent {
|
||||
+impl PartialEq for NotificationEvent {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
-impl Eq for NotifyEvent {}
|
||||
+impl Eq for NotificationEvent {}
|
||||
|
||||
-impl PartialOrd for NotifyEvent {
|
||||
+impl PartialOrd for NotificationEvent {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(
|
||||
self.effective_priority
|
||||
@@ -83,7 +83,7 @@ impl PartialOrd for NotifyEvent {
|
||||
}
|
||||
}
|
||||
|
||||
-impl Ord for NotifyEvent {
|
||||
+impl Ord for NotificationEvent {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.partial_cmp(other).unwrap()
|
||||
}
|
||||
@@ -8,4 +8,29 @@
|
||||
- [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/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)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Decorator 速查表
|
||||
|
||||
bake.sh 中所有可用的 `# @decorator` 注解:
|
||||
|
||||
| Decorator | 语法 | 作用 | 示例 |
|
||||
|-----------|------|------|------|
|
||||
| `@pipeline` | `# @pipeline` | 标记为主构建步骤 | 无参数 |
|
||||
| `@after` | `# @after(func_name)` | 指定依赖的前置步骤 | `# @after(build)` |
|
||||
| `@timeout` | `# @timeout(N)` | 超时时间(秒) | `# @timeout(300)` |
|
||||
| `@retry` | `# @retry(count, delay)` | 失败重试次数与间隔(秒) | `# @retry(3, 10)` |
|
||||
| `@if` | `# @if(condition)` | 条件执行(shell 条件表达式) | `# @if([ -f Makefile ])` |
|
||||
| `@fallible` | `# @fallible` | 允许失败,不中断流水线 | 无参数 |
|
||||
| `@parallel` | `# @parallel` | 与其他 `@parallel` 步骤并发执行 | 无参数 |
|
||||
| `@loop` | `# @loop(N)` | 重复执行 N 次 | `# @loop(5)` |
|
||||
| `@export` | `# @export` | 导出环境变量供后续步骤使用 | 无参数 |
|
||||
| `@pipe` | `# @pipe(f1, f2, ...)` | 管道串联多个函数 | `# @pipe(lint, build)` |
|
||||
| `@daemon` | `# @daemon` | 作为后台守护进程运行 | 无参数 |
|
||||
| `@health` | `# @health(endpoint)` | 指定健康检查端点 | `# @health(/health)` |
|
||||
|
||||
## 语法规则
|
||||
|
||||
- `#` 和 `@` 之间必须有空格:`# @pipeline` 合法,`#@pipeline` 不合法
|
||||
- `@name` 和 `(args)` 之间不能有空格:`@timeout(60)` 合法,`@timeout (60)` 不合法
|
||||
- 每个 decorator 独占一行,位于目标函数定义的上方
|
||||
|
||||
## 常见组合
|
||||
|
||||
```bash
|
||||
# @pipeline
|
||||
# @after(setup)
|
||||
# @timeout(600)
|
||||
# @retry(2, 30)
|
||||
build() {
|
||||
cargo build --release
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,129 @@
|
||||
# 部署
|
||||
|
||||
## 安装
|
||||
|
||||
### 从源码构建
|
||||
|
||||
```bash
|
||||
cargo build --release -p workshop-baker
|
||||
```
|
||||
|
||||
编译输出位于 `target/release/workshop-baker`。
|
||||
|
||||
### 下载预编译二进制
|
||||
|
||||
从项目 Release 页面下载对应平台的预编译二进制文件,放置到 `$PATH` 可达路径即可。
|
||||
|
||||
## 运行模式
|
||||
|
||||
Baker 支持两种运行模式:
|
||||
|
||||
### CLI 模式
|
||||
|
||||
单次执行模式,每次运行完成一个构建阶段后退出:
|
||||
|
||||
```bash
|
||||
# 预烘焙阶段
|
||||
workshop-baker -u <username> -p <pipeline> -b <build-id> bare prebake config.yml
|
||||
|
||||
# 烘焙阶段
|
||||
workshop-baker -u <username> -p <pipeline> -b <build-id> bare bake script.sh \
|
||||
--bake-base ./bake_base.sh --prebake ./prebake.yml
|
||||
|
||||
# 终结阶段
|
||||
workshop-baker -u <username> -p <pipeline> -b <build-id> bare finalize config.yml
|
||||
```
|
||||
|
||||
### Daemon 模式
|
||||
|
||||
常驻后台模式通过 Socket 监听请求,支持多构建任务管理。当前处于重构中,暂未实现。Daemon 模式下不指定 `bare` 子命令即可进入:
|
||||
|
||||
```bash
|
||||
workshop-baker -u <username> -p <pipeline> -b <build-id>
|
||||
```
|
||||
|
||||
## 预烘焙环境
|
||||
|
||||
prebake 阶段负责准备构建运行环境,支持三种方式:
|
||||
|
||||
### Docker
|
||||
|
||||
在容器内执行构建,通过配置文件指定容器参数:
|
||||
|
||||
```yaml
|
||||
prebake:
|
||||
type: docker
|
||||
image: "rust:1.85"
|
||||
dockerfile: "Dockerfile.build"
|
||||
build_args:
|
||||
- "CARGO_PROFILE=release"
|
||||
```
|
||||
|
||||
- `image` — 指定预构建的 Docker 镜像
|
||||
- `dockerfile` — 指定自定义 Dockerfile 路径(与 `image` 二选一)
|
||||
- `build_args` — Docker 构建参数列表
|
||||
|
||||
### Firecracker
|
||||
|
||||
在 microVM 内执行构建,提供更强的隔离性:
|
||||
|
||||
```yaml
|
||||
prebake:
|
||||
type: firecracker
|
||||
kernel: "/path/to/vmlinux"
|
||||
rootfs: "/path/to/rootfs.ext4"
|
||||
vcpu_count: 2
|
||||
mem_size_mb: 512
|
||||
```
|
||||
|
||||
### Baremetal
|
||||
|
||||
直接在宿主机运行构建,不使用容器或虚拟机隔离:
|
||||
|
||||
```yaml
|
||||
prebake:
|
||||
type: baremetal
|
||||
pkgman: "apt"
|
||||
elevate: true
|
||||
```
|
||||
|
||||
## Systemd 服务
|
||||
|
||||
以下为 Baker Daemon 的 systemd unit 文件示例:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=HoneyBiscuitWorkshop Baker Daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/workshop-baker -u vulcan -p default -b 0 --socket unix:///run/hbw.sock
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=RUST_LOG=info
|
||||
Environment=HBW_WORKSPACE=/workspace
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
部署步骤:
|
||||
|
||||
```bash
|
||||
# 安装 unit 文件
|
||||
sudo cp workshop-baker.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now workshop-baker
|
||||
```
|
||||
|
||||
## 权限模型
|
||||
|
||||
Baker 采用"先提权后降权"的权限模型:
|
||||
|
||||
| 阶段 | 执行用户 | 原因 |
|
||||
|------|----------|------|
|
||||
| prebake | root | 需要创建容器/虚拟机、挂载文件系统等特权操作 |
|
||||
| bake | vulcan | 降权执行,限制构建过程权限 |
|
||||
|
||||
prebake 阶段以 root 身份运行,完成环境准备工作后,Baker 会将执行权限降级为 `vulcan` 用户再进入 bake 阶段,确保构建过程不会以 root 权限运行。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 环境变量
|
||||
|
||||
HoneyBiscuitWorkshop 通过以下环境变量控制构建行为:
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `HBW_USERNAME` | 提交者用户名(来自 base / server) | — |
|
||||
| `HBW_PIPELINE` | 流水线标识符 | — |
|
||||
| `HBW_BUILD_ID` | 构建唯一 ID | — |
|
||||
| `HBW_DRY_RUN` | 干运行模式,不执行实际构建 | — |
|
||||
| `HBW_WORKSPACE` | 工作空间路径 | `/workspace` |
|
||||
| `RUST_LOG` | 日志级别 (error/warn/info/debug/trace) | `warn` |
|
||||
|
||||
### HBW_USERNAME
|
||||
|
||||
提交者用户名,由上游 server / base 系统传入,标识触发本次构建的用户。与构建系统内的 Unix/Windows 用户名(如 `vulcan`)无关,仅用于日志、通知与审计追踪。
|
||||
|
||||
### HBW_PIPELINE
|
||||
|
||||
标识当前运行的流水线。在 prebake 阶段自动从配置文件获取,贯穿整个构建生命周期。
|
||||
|
||||
### HBW_BUILD_ID
|
||||
|
||||
每次构建的唯一标识符,用于日志追踪和状态标识。
|
||||
|
||||
### HBW_DRY_RUN
|
||||
|
||||
设置为任意非空值时启用干运行模式。该模式下 Baker 会解析流水线配置并生成执行计划,但不会执行实际构建操作。适用于配置验证和调试。
|
||||
|
||||
### HBW_WORKSPACE
|
||||
|
||||
构建工作空间的绝对路径。所有构建输出和中间文件都将输出到此目录下。
|
||||
|
||||
### RUST_LOG
|
||||
|
||||
控制日志输出级别,遵循 `tracing` crate 的规范:
|
||||
|
||||
- `error` — 仅输出错误
|
||||
- `warn` — 输出警告和错误
|
||||
- `info` — 输出一般信息
|
||||
- `debug` — 输出调试信息
|
||||
- `trace` — 输出全部跟踪信息
|
||||
|
||||
支持模块级别过滤,例如 `RUST_LOG=workshop_baker=trace` 仅跟踪 Baker 模块。
|
||||
@@ -0,0 +1,20 @@
|
||||
# 退出码速查
|
||||
|
||||
| 退出码 | 常量名 | 含义 | 典型触发场景 |
|
||||
|--------|--------|------|-------------|
|
||||
| 0 | `EXITCODE_OK` | 成功 | 流水线正常完成 |
|
||||
| 1 | `EXITCODE_GENERAL_ERROR` | 通用错误 | 未分类的运行时错误 |
|
||||
| 2 | `EXITCODE_INVALID_ARGS` | 参数错误 | 命令行参数或配置无效 |
|
||||
| 3 | `EXITCODE_IO_ERROR` | IO 错误 | 文件读写、目录创建失败 |
|
||||
| 4 | `EXITCODE_PARSE_ERROR` | 解析错误 | YAML 配置或 Decorator 语法错误 |
|
||||
| 5 | `EXITCODE_EXECUTION_ERROR` | 执行错误 | 构建脚本执行失败 |
|
||||
| 124 | `EXITCODE_TIMEOUT` | 超时 | 构建超过 `@timeout()` 限制 |
|
||||
| 201 | `EXITCODE_PRIV_DROP_FAILED` | 权限降级失败 | 无法从 root 切换到 vulcan 用户 |
|
||||
| 233 | `PIPELINE_SKIP_ERRORCODE` | 流水线跳过 | `@if` 条件不满足,步骤跳过 |
|
||||
|
||||
## 排查命令
|
||||
|
||||
```bash
|
||||
# 结合日志定位具体错误
|
||||
RUST_LOG=debug workshop-baker -u testuser -p my-pipeline -b 1 bare bake script.sh 2>&1 | grep -i "error\|fail"
|
||||
```
|
||||
@@ -0,0 +1,91 @@
|
||||
# Finalize 插件参考
|
||||
|
||||
Finalize 阶段通过插件系统完成打包、部署与通知。
|
||||
|
||||
## 插件类型
|
||||
|
||||
| 类型 | 说明 | 配置位置 |
|
||||
|------|------|---------|
|
||||
| `internal` | 内置插件(随 baker 编译) | `finalize.yml` 的 `notification` 下直接配置 |
|
||||
| `shell` | 可执行脚本插件 | 提供 `manifest.yml` 描述文件 |
|
||||
| `dylib` | 动态链接库插件 | 通过 `dlopen` 加载 |
|
||||
| `rhai` | Rhai 脚本引擎插件 | `.rhai` 脚本文件(暂未实现) |
|
||||
|
||||
## 内置插件清单
|
||||
|
||||
### mail — SMTP 邮件通知
|
||||
|
||||
发送构建结果邮件,支持 HTML 模板与多收件人。
|
||||
|
||||
| 字段 | 必需 | 类型 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `to` | 是 | String / List | 收件人地址,支持模板变量 |
|
||||
| `from` | 是 | String / Object | 发件人,单字符串为 email,Object 可含 `name` 与 `email` |
|
||||
| `cc` | 否 | List | 抄送 |
|
||||
| `bcc` | 否 | List | 密送 |
|
||||
| `reply_to` | 否 | String | 回复地址 |
|
||||
| `subject` | 否 | String | 邮件主题,支持模板语法 |
|
||||
| `body` | 否 | String | 邮件正文,支持模板语法与 HTML |
|
||||
| `schema` | 否 | String | `"default"` 或 `"custom"`,控制是否使用内置模板 |
|
||||
| `smtp` | 是 | Object | SMTP 服务器配置 |
|
||||
| `timeout` | 否 | String | 发送超时,如 `"90s"` |
|
||||
| `retry` | 否 | Integer | 失败重试次数 |
|
||||
| `fallible` | 否 | Boolean | 允许失败不中断流水线 |
|
||||
|
||||
#### smtp 子字段
|
||||
|
||||
| 字段 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `host` | 是 | SMTP 服务器地址 |
|
||||
| `port` | 是 | 端口号 |
|
||||
| `auth.type` | 否 | `"password"` / `"oauth2"` / `"none"` |
|
||||
| `auth.username` | 条件 | `type=password` 时必需 |
|
||||
| `auth.password` | 条件 | `type=password` 时必需 |
|
||||
| `encryption` | 否 | `"tls"` / `"starttls"` / `"none"` |
|
||||
| `connect_timeout` | 否 | 连接超时 |
|
||||
|
||||
#### 模板变量
|
||||
|
||||
| 变量 | 示例值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `{{ build.status }}` | `success` | 构建状态 |
|
||||
| `{{ build.id }}` | `42` | 构建编号 |
|
||||
| `{{ build.duration }}` | `125s` | 构建耗时 |
|
||||
| `{{ build.url }}` | `https://ci.example.com/42` | 构建详情链接 |
|
||||
| `{{ pipeline.name }}` | `my-project` | 流水线名称 |
|
||||
| `{{ commit.hash }}` | `abc1234` | 提交哈希 |
|
||||
| `{{ commit.author }}` | `Alice` | 提交者 |
|
||||
| `{{ commit.message }}` | `fix: typo` | 提交信息 |
|
||||
| `{{ secret.xxx }}` | — | Vault 密钥引用 |
|
||||
|
||||
### webhook — HTTP 回调
|
||||
|
||||
向外部服务发送 HTTP POST 请求。
|
||||
|
||||
| 字段 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `url` | 是 | 回调地址 |
|
||||
| `method` | 否 | HTTP 方法,默认 `POST` |
|
||||
| `headers` | 否 | 请求头键值对 |
|
||||
| `body` | 否 | 请求体,支持模板变量 |
|
||||
| `timeout` | 否 | 请求超时 |
|
||||
| `retry` | 否 | 失败重试次数 |
|
||||
| `fallible` | 否 | 允许失败 |
|
||||
|
||||
## 最小配置示例
|
||||
|
||||
```yaml
|
||||
notification:
|
||||
1:
|
||||
mail:
|
||||
to: "admin@example.com"
|
||||
from: "ci@example.com"
|
||||
smtp:
|
||||
host: "smtp.gmail.com"
|
||||
port: 587
|
||||
auth:
|
||||
type: "password"
|
||||
username: "{{ secret.smtp_user }}"
|
||||
password: "{{ secret.smtp_pass }}"
|
||||
encryption: "starttls"
|
||||
```
|
||||
@@ -0,0 +1,119 @@
|
||||
# Prebake 配置参考
|
||||
|
||||
`prebake.yml` 定义构建环境的准备阶段。
|
||||
|
||||
## 顶层字段
|
||||
|
||||
| 字段 | 必需 | 类型 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `version` | 是 | String | — | 语义化版本,须满足 `^1.0` |
|
||||
| `environment` | 是 | Object | — | 构建环境类型与资源配置 |
|
||||
| `bootstrap` | 否 | Object | 见下文 | 用户、工作目录初始化 |
|
||||
| `dependencies` | 否 | Object | — | 系统包与用户包依赖 |
|
||||
| `envvars` | 否 | Object | — | 环境变量注入 |
|
||||
| `cache` | 否 | Object | — | 缓存目录与策略 |
|
||||
| `security` | 否 | Object | — | 权限降级配置 |
|
||||
| `hooks` | 否 | Object | — | 前置/后置钩子 |
|
||||
| `metadata` | 否 | Object | — | 流水线元信息 |
|
||||
|
||||
## environment
|
||||
|
||||
| 字段 | 必需 | 类型 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `builder` | 是 | String | — | `baremetal` / `docker` / `firecracker` / `custom` |
|
||||
| `resources` | 否 | Object | 见下文 | CPU、内存、磁盘、架构约束 |
|
||||
| `network` | 否 | Object | 见下文 | 网络开关与代理 |
|
||||
|
||||
### resources 默认值
|
||||
|
||||
| 字段 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `cpu` | `1` | vCPU 数量 |
|
||||
| `memory` | `1 GiB` | 内存限制 |
|
||||
| `disk` | `1 GiB` | 磁盘限制 |
|
||||
| `architecture` | `[]` | 允许的目标架构,支持字符串或数组 |
|
||||
| `tags` | `[]` | 节点标签筛选 |
|
||||
|
||||
### network
|
||||
|
||||
| 字段 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `enabled` | `true` | 是否启用网络 |
|
||||
| `outbound` | `true` | 是否允许出站连接 |
|
||||
| `dns_servers` | `[]` | 自定义 DNS 服务器列表 |
|
||||
| `proxies` | `[]` | HTTP/HTTPS 代理配置 |
|
||||
|
||||
## bootstrap
|
||||
|
||||
| 字段 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `user` | `"vulcan"` | 构建用户 |
|
||||
| `workspace.path` | `"/home/vulcan/workspace"` | 工作目录 |
|
||||
| `workspace.fallback` | `true` | 目录不存在时自动创建 |
|
||||
| `sudoers` | `null` | sudoers 文件内容 |
|
||||
| `doas` | `null` | doas.conf 文件内容 |
|
||||
| `custom` | `null` | 自定义初始化脚本 |
|
||||
|
||||
## dependencies
|
||||
|
||||
### system
|
||||
|
||||
以包管理器名为键,每个值包含:
|
||||
|
||||
| 字段 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `packages` | 是 | 包名列表 |
|
||||
| `repositories` | 否 | 额外软件源配置 |
|
||||
| `mirror` | 否 | 镜像地址 |
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
system:
|
||||
pacman:
|
||||
packages: ["curl", "wget"]
|
||||
```
|
||||
|
||||
### user
|
||||
|
||||
以包管理器名为键,值为自由格式(由对应 UPM 解析)。
|
||||
|
||||
## envvars
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `prebake` | 在 prebake 阶段注入的环境变量 |
|
||||
| `bake` | 在 bake 阶段注入的环境变量 |
|
||||
|
||||
## cache
|
||||
|
||||
| 字段 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `directory` | 是 | 缓存目录列表,每项含 `path` 与可选 `strategy` |
|
||||
| `strategy` | 否 | 缓存策略配置 |
|
||||
|
||||
## 完整示例
|
||||
|
||||
```yaml
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
resources:
|
||||
cpu: 2
|
||||
memory: "4 GiB"
|
||||
architecture: ["x86_64", "aarch64"]
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
workspace:
|
||||
path: "/home/vulcan/workspace"
|
||||
fallback: true
|
||||
dependencies:
|
||||
system:
|
||||
pacman:
|
||||
packages: ["rust", "git"]
|
||||
envvars:
|
||||
prebake:
|
||||
RUST_BACKTRACE: "1"
|
||||
cache:
|
||||
directory:
|
||||
- path: "/var/cache/pacman"
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
# Socket 配置
|
||||
|
||||
Baker Daemon 通过 Socket 与 CLI 通信,支持以下连接方式:
|
||||
|
||||
- **Unix Domain Socket**: `unix://path` — 使用本地 Unix 域套接字,适用于同机通信
|
||||
```bash
|
||||
workshop-baker -u vulcan -p default -b 0 --socket unix:///run/hbw.sock
|
||||
```
|
||||
- **TCP Socket**: `tcp://host:port` — 使用 TCP 连接,适用于远程管理
|
||||
```bash
|
||||
workshop-baker -u vulcan -p default -b 0 --socket tcp://127.0.0.1:9090
|
||||
```
|
||||
- **Dryrun 模式**: `dryrun` — 测试模式,不创建实际连接
|
||||
```bash
|
||||
workshop-baker -u vulcan -p default -b 0 --socket dryrun
|
||||
```
|
||||
|
||||
默认使用 `unix:///run/hbw.sock`。
|
||||
@@ -0,0 +1,33 @@
|
||||
# 构建状态文件格式
|
||||
|
||||
Baker 将构建状态写入 `/tmp/.hbwstatus`,格式为 **JSON Lines**(每行一条独立 JSON)。
|
||||
|
||||
## 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `name` | String | 阶段名称 |
|
||||
| `phase` | String | 执行阶段:`prebake` / `bake` / `finalize` |
|
||||
| `substage` | String | 子阶段标识 |
|
||||
| `result` | String | 结果:`success` / `failure` / `canceled` / `skipped` |
|
||||
| `duration_ms` | u64 | 执行耗时(毫秒) |
|
||||
| `started_at` | ISO 8601 | 开始时间 |
|
||||
| `finished_at` | ISO 8601 | 结束时间 |
|
||||
| `error_message` | String/Null | 失败时的错误信息 |
|
||||
|
||||
## 示例
|
||||
|
||||
```json
|
||||
{"name":"bootstrap","phase":"Prebake","substage":"bootstrap","result":"success","duration_ms":1500,"started_at":"2026-04-25T10:00:00Z","finished_at":"2026-04-25T10:00:01.5Z","error_message":null}
|
||||
{"name":"build","phase":"Bake","substage":"build","result":"failure","duration_ms":30000,"started_at":"2026-04-25T10:01:00Z","finished_at":"2026-04-25T10:01:30Z","error_message":"cargo build failed with exit code 101"}
|
||||
```
|
||||
|
||||
## 读取状态
|
||||
|
||||
```bash
|
||||
# 查看最新阶段
|
||||
tail -1 /tmp/.hbwstatus | jq .
|
||||
|
||||
# 统计失败阶段数
|
||||
cat /tmp/.hbwstatus | jq -s 'map(select(.result == "failure")) | length'
|
||||
```
|
||||
@@ -0,0 +1,102 @@
|
||||
# 故障排除
|
||||
|
||||
## 日志调试
|
||||
|
||||
启用详细日志输出以定位问题:
|
||||
|
||||
```bash
|
||||
# 查看 Baker 详细日志
|
||||
RUST_LOG=debug workshop-baker -u testuser -p my-pipeline -b 1 bare bake script.sh
|
||||
|
||||
# 查看特定模块日志
|
||||
RUST_LOG=workshop_baker=trace workshop-baker -u testuser -p my-pipeline -b 1 bare bake script.sh
|
||||
|
||||
# 组合过滤
|
||||
RUST_LOG=workshop_baker=debug,workshop_pipeline=trace workshop-baker -u testuser -p my-pipeline -b 1 bare bake script.sh
|
||||
```
|
||||
|
||||
常用日志级别组合:
|
||||
|
||||
- `RUST_LOG=info` — 生产环境推荐
|
||||
- `RUST_LOG=debug` — 一般调试
|
||||
- `RUST_LOG=workshop_baker=trace` — 深度调试 Baker 内部逻辑
|
||||
|
||||
## 退出码对照表
|
||||
|
||||
| 退出码 | 含义 | 说明 |
|
||||
|--------|------|------|
|
||||
| 0 | OK | 构建成功完成 |
|
||||
| 1 | 通用错误 | 未分类的运行时错误 |
|
||||
| 2 | 参数错误 | 命令行参数或配置无效 |
|
||||
| 3 | IO 错误 | 文件读写失败 |
|
||||
| 4 | 解析错误 | YAML 配置或 Decorator 语法解析失败 |
|
||||
| 5 | 执行错误 | 构建脚本执行失败 |
|
||||
| 124 | 超时 | 构建超过 `@timeout()` 指定的时间限制 |
|
||||
| 201 | 权限降级失败 | 无法从 root 切换到 vulcan 用户 |
|
||||
| 233 | 流水线跳过 | 流水线条件不满足,跳过执行 |
|
||||
|
||||
## 常见问题与解决
|
||||
|
||||
### Socket 连接失败
|
||||
|
||||
**现象**: CLI 无法连接 Daemon,报 Connection refused 或 Permission denied。
|
||||
|
||||
**解决**: 检查 Unix socket 路径是否存在以及文件权限:
|
||||
|
||||
```bash
|
||||
# 检查 socket 文件
|
||||
ls -la /run/hbw.sock
|
||||
|
||||
# 确认 Daemon 正在运行
|
||||
pgrep -a workshop-baker
|
||||
|
||||
# 检查 socket 权限
|
||||
# 确保当前用户有读写权限,或使用 sudo
|
||||
```
|
||||
|
||||
### Decorator 解析错误
|
||||
|
||||
**现象**: 构建脚本的 Decorator 注解无法识别。
|
||||
|
||||
**解决**: Decorator 语法必须严格遵循 `# @name(args)` 格式。常见错误:
|
||||
|
||||
```bash
|
||||
# 错误 — @ 符号前缺少空格
|
||||
#@timeout(60)
|
||||
|
||||
# 错误 — 名称后有空格
|
||||
# @ timeout(60)
|
||||
|
||||
# 正确
|
||||
# @timeout(60)
|
||||
```
|
||||
|
||||
注意:`#` 和 `@` 之间必须有空格,`@name` 和 `(args)` 之间不能有空格。
|
||||
|
||||
### Hook 执行权限拒绝
|
||||
|
||||
**现象**: prebake 或 finalize 阶段的 Hook 脚本报 Permission denied。
|
||||
|
||||
**解决**: 检查 `vulcan` 用户的 sudoers 配置,确保其有执行必要操作的权限:
|
||||
|
||||
```bash
|
||||
# 检查 sudoers 配置
|
||||
sudo visudo -c
|
||||
|
||||
# 确认 vulcan 用户权限
|
||||
sudo -lU vulcan
|
||||
```
|
||||
|
||||
### 包管理器检测失败
|
||||
|
||||
**现象**: baremetal 模式下 Baker 无法检测系统包管理器。
|
||||
|
||||
**解决**: 检查 `/etc/os-release` 文件是否存在且内容完整:
|
||||
|
||||
```bash
|
||||
# 检查文件
|
||||
cat /etc/os-release
|
||||
|
||||
# 常见缺失情况:Docker 最小镜像或 musl 环境
|
||||
# 需手动安装 base-files 或创建该文件
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
# 工作空间
|
||||
|
||||
工作空间是 Baker 执行构建的根目录,默认路径为 `/workspace`。目录结构如下:
|
||||
|
||||
```
|
||||
/workspace/
|
||||
├── src/ # 源代码检出目录
|
||||
├── build/ # 编译中间输出
|
||||
├── output/ # 构建最终输出
|
||||
└── cache/ # 构建缓存
|
||||
```
|
||||
|
||||
可通过 `HBW_WORKSPACE` 环境变量或配置文件覆盖默认路径。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 添加包管理器
|
||||
|
||||
要支持新的系统包管理器,需要实现 `PackageManager` trait。
|
||||
|
||||
```rust
|
||||
trait PackageManager {
|
||||
fn name(&self) -> &str;
|
||||
fn install(&self, packages: &[&str]) -> Result<()>;
|
||||
fn update(&self) -> Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
参考 `workshop-baker/src/engine/pm/pacman.rs` 中的实现。步骤如下:
|
||||
|
||||
1. 在 `workshop-baker/src/engine/pm/` 下创建新文件(如 `yum.rs`)。
|
||||
2. 实现 `PackageManager` trait 的所有方法。
|
||||
3. 在 `pm/mod.rs` 中注册新变体到 `PackageManagerEnum`。
|
||||
4. 在配置解析中增加对应的匹配分支。
|
||||
|
||||
## 示例:pacman 实现
|
||||
|
||||
```rust
|
||||
pub struct PacmanManager;
|
||||
|
||||
impl PackageManager for PacmanManager {
|
||||
fn name(&self) -> &str { "pacman" }
|
||||
|
||||
fn install(&self, packages: &[&str]) -> Result<()> {
|
||||
let args = vec!["-S", "--noconfirm"].iter()
|
||||
.chain(packages.iter())
|
||||
.collect::<Vec<_>>();
|
||||
Command::new("pacman").args(&args).status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update(&self) -> Result<()> {
|
||||
Command::new("pacman").args(["-Syu", "--noconfirm"]).status()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,87 @@
|
||||
# 架构
|
||||
|
||||
## 整体架构
|
||||
|
||||
当前活跃维护的 crate:
|
||||
|
||||
- **workshop-baker** 是核心编排器,负责流水线的调度与执行,提供 CLI 和 Daemon 两种运行模式。
|
||||
- **workshop-vault** 封装 HashiCorp Vault 客户端,提供密钥的读写与引用解析。
|
||||
- **workshop-cert** 负责生成自签名 TLS 证书。
|
||||
- **workshop-deviceid** 提供设备唯一标识,用于多节点环境下的节点识别。
|
||||
|
||||
其余模块(如 workshop-agent、workshop-llm-detector、workshop-pipeline 等)已归档至 `archived/` 目录,不再活跃维护。
|
||||
|
||||
## 数据流
|
||||
|
||||
流水线分为三个阶段,顺序执行:
|
||||
|
||||
1. **Prebake(环境准备)**:根据 prebake.yml 配置创建构建环境,支持 Docker、Firecracker 和 Bare Metal 三种模式。包括包安装、用户创建、安全降权等。
|
||||
2. **Bake(脚本执行)**:加载 bake.sh 脚本,解析 `# @decorator` 注解,进行拓扑排序后按依赖顺序执行构建步骤。
|
||||
3. **Finalize(后处理)**:执行打包、部署、通知等收尾工作,通过插件系统完成邮件发送、Webhook 回调等操作。
|
||||
|
||||
三个阶段通过 `ExecutionContext` 串联,上下文信息在阶段间传递。
|
||||
|
||||
## 核心模块详解
|
||||
|
||||
### Engine
|
||||
|
||||
Engine 是 Bake 阶段的核心执行引擎,主要包含:
|
||||
|
||||
- **Executor**:负责脚本的执行,管理子进程的生命周期和输出捕获。
|
||||
- **PackageManager**:包管理抽象 trait,支持 pacman、apt 等多种包管理器的统一调用。
|
||||
|
||||
### Bake
|
||||
|
||||
Bake 模块处理构建脚本解析和调度:
|
||||
|
||||
- **Parser**:解析 bake.sh 中的 `# @decorator` 注解,提取超时、重试、并行等指令。
|
||||
- **Schedule**:基于依赖关系对构建步骤进行拓扑排序,支持并行执行无依赖的步骤。
|
||||
- **Builder**:使用模板引擎渲染构建脚本,支持变量替换和条件逻辑。
|
||||
|
||||
### Prebake
|
||||
|
||||
Prebake 模块负责构建环境准备:
|
||||
|
||||
- **Config**:解析 prebake.yml,提取环境类型(Docker/Firecracker/Bare Metal)和配置项。
|
||||
- **Bootstrap**:生成环境启动脚本,包括系统初始化和工具安装。
|
||||
- **Security**:处理权限降级,创建构建用户(默认 vulcan)并设置目录权限。
|
||||
|
||||
### Finalize
|
||||
|
||||
Finalize 模块完成后处理工作:
|
||||
|
||||
- **Plugin**:插件系统,支持 Shell、Dylib、Rhai 和 Internal 四种插件类型。
|
||||
- **Hook**:在流水线关键节点执行钩子函数,如构建成功后的通知发送。
|
||||
|
||||
## 类型系统
|
||||
|
||||
核心类型贯穿三个阶段:
|
||||
|
||||
- **ExecutionContext**:在 Prebake → Bake → Finalize 间传递的上下文对象,包含环境信息、变量和构建状态。
|
||||
- **ResourceLimits**:定义 CPU、内存、磁盘等资源限制,用于 CgroupManager 配置。
|
||||
- **BuildStatus**:表示构建状态(Pending/Running/Success/Failed/Cancelled),贯穿整个生命周期。
|
||||
|
||||
## 插件架构
|
||||
|
||||
插件系统基于 `Plugin` trait 和 `AsyncPluginFn` 类型别名:
|
||||
|
||||
```rust
|
||||
trait Plugin {
|
||||
async fn execute(&self, ctx: &ExecutionContext) -> Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
### 内部插件
|
||||
|
||||
内部插件随 baker 编译,直接调用 Rust 代码:
|
||||
|
||||
- **Mail**:基于 SMTP 发送构建通知邮件,支持 minijinja 模板。
|
||||
- **Webhook**:向外部服务发送 HTTP 回调。
|
||||
|
||||
### 外部插件
|
||||
|
||||
外部插件运行在 baker 进程之外:
|
||||
|
||||
- **Shell**:可执行脚本,通过子进程调用,需提供 `manifest.yml` 描述文件。
|
||||
- **Dylib**:动态链接库,通过 `dlopen` 加载并调用导出函数。
|
||||
- **Rhai**:使用 Rhai 脚本引擎执行 `.rhai` 脚本文件。
|
||||
@@ -0,0 +1,30 @@
|
||||
# 代码规范
|
||||
|
||||
## 命名约定
|
||||
|
||||
| 类型 | 风格 | 示例 |
|
||||
|------|------|------|
|
||||
| 文件/模块 | snake_case | `engine.rs`, `pm/` |
|
||||
| 类型/结构体/枚举 | PascalCase | `PipelineConfig`, `BuildStatus` |
|
||||
| 常量 | SCREAMING_SNAKE_CASE | `MAX_RETRIES`, `DEFAULT_TIMEOUT` |
|
||||
| 函数/变量 | snake_case | `parse_decorator`, `build_id` |
|
||||
|
||||
## 导入顺序
|
||||
|
||||
按以下顺序组织 `use` 语句,每组之间空一行:
|
||||
|
||||
1. `std` 标准库
|
||||
2. 外部 crate
|
||||
3. `crate::` 内部模块
|
||||
|
||||
```rust
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::bake::Parser;
|
||||
use crate::engine::Executor;
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# 提交规范
|
||||
|
||||
提交信息使用类型前缀:
|
||||
|
||||
| 前缀 | 说明 |
|
||||
|------|------|
|
||||
| `feat` | 新功能 |
|
||||
| `fix` | Bug 修复 |
|
||||
| `docs` | 文档变更 |
|
||||
| `test` | 测试相关 |
|
||||
| `refactor` | 重构 |
|
||||
| `style` | 代码风格(不影响功能) |
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
feat(baker): add timeout support for build steps
|
||||
fix(agent): fix TLS certificate reload on SIGHUP
|
||||
docs: update contributing guide
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
# 自定义 Decorator
|
||||
|
||||
在 bake.sh 脚本中,`# @decorator` 注解用于控制构建行为。添加自定义 Decorator 的步骤:
|
||||
|
||||
1. 在 `workshop-baker/src/bake/decorator.rs` 中的 `Decorator` enum 添加新变体:
|
||||
|
||||
```rust
|
||||
pub enum Decorator {
|
||||
Timeout { ms: u64 },
|
||||
Retry { count: u32 },
|
||||
Parallel,
|
||||
Fallible,
|
||||
MyCustom { value: String }, // 新增
|
||||
}
|
||||
```
|
||||
|
||||
2. 在 `parse_decorator` 函数的 match 中添加解析逻辑:
|
||||
|
||||
```rust
|
||||
fn parse_decorator(line: &str) -> Option<Decorator> {
|
||||
match decorator_name {
|
||||
"timeout" => Some(Decorator::Timeout { ms: ... }),
|
||||
"retry" => Some(Decorator::Retry { count: ... }),
|
||||
"parallel" => Some(Decorator::Parallel),
|
||||
"fallible" => Some(Decorator::Fallible),
|
||||
"my_custom" => Some(Decorator::MyCustom { value: ... }), // 新增
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. 在调度器中处理新 Decorator 的执行逻辑。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 自定义插件
|
||||
|
||||
## Shell 插件
|
||||
|
||||
Shell 插件是最简单的外部插件形式,只需提供一个可执行脚本和 `manifest.yml`。
|
||||
|
||||
**可执行脚本** (`my-plugin.sh`):
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
echo "Running custom plugin"
|
||||
```
|
||||
|
||||
**manifest.yml 格式**:
|
||||
|
||||
```yaml
|
||||
name: my-plugin
|
||||
version: "1.0"
|
||||
type: shell
|
||||
command: ./my-plugin.sh
|
||||
timeout: 300
|
||||
env:
|
||||
MY_VAR: "hello"
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| name | 插件名称 |
|
||||
| version | 插件版本 |
|
||||
| type | 插件类型:`shell` / `dylib` / `rhai` |
|
||||
| command | 可执行文件路径(shell 类型) |
|
||||
| timeout | 超时时间(秒) |
|
||||
| env | 环境变量映射 |
|
||||
|
||||
## Dylib 插件
|
||||
|
||||
动态链接库,通过 `dlopen` 加载并调用约定符号的导出函数。需确保 ABI 兼容。
|
||||
|
||||
## Rhai 插件
|
||||
|
||||
使用 Rhai 脚本引擎执行 `.rhai` 文件。当前为 `todo!()` 占位,尚未实现。
|
||||
@@ -0,0 +1,15 @@
|
||||
# 开发环境
|
||||
|
||||
本项目要求:
|
||||
|
||||
- **Rust 1.85+**(使用 edition 2024)
|
||||
- **Docker**(集成测试需要)
|
||||
- **mdBook**(文档构建,可选)
|
||||
|
||||
初始化开发环境:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/HoneyBiscuitWorkshop.git
|
||||
cd HoneyBiscuitWorkshop
|
||||
cargo build
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
# Engine 模块指南
|
||||
|
||||
`engine/` 是 Baker 的运行时层,负责构建脚本执行、资源隔离与包管理。
|
||||
|
||||
## 模块划分
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `executor.rs` | 脚本执行,管理子进程生命周期与输出捕获 |
|
||||
| `cgroups.rs` | Linux cgroup v2 资源限制配置(CPU、内存、IO) |
|
||||
| `pm.rs` | 系统包管理器检测与抽象 trait |
|
||||
| `pm/pacman.rs` | Pacman 包管理器实现 |
|
||||
| `upm.rs` | 用户级包管理器(Nix、Guix、Cargo、NPM 等) |
|
||||
| `repology.rs` | Repology 包名查询与版本解析 |
|
||||
| `repology/local.rs` | 本地包数据库缓存 |
|
||||
| `socket.rs` | Unix Domain Socket 通信(daemon 模式) |
|
||||
|
||||
## PackageManager Trait
|
||||
|
||||
系统包管理器的统一接口:
|
||||
|
||||
```rust
|
||||
trait PackageManager {
|
||||
fn name(&self) -> &str;
|
||||
fn install(&self, packages: &[&str]) -> Result<()>;
|
||||
fn update(&self) -> Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
新增包管理器时:
|
||||
1. 在 `pm/` 下新建文件实现 `PackageManager`
|
||||
2. 在 `pm.rs` 的 `PackageManagerEnum` 中注册变体
|
||||
3. 在 `PrebakeConfig` 解析中增加匹配分支
|
||||
|
||||
## 资源限制
|
||||
|
||||
`cgroups.rs` 通过 Linux cgroup v2 限制构建资源:
|
||||
|
||||
| 限制项 | cgroup 文件 | 配置来源 |
|
||||
|--------|-------------|---------|
|
||||
| CPU 配额 | `cpu.max` | `resources.cpu` |
|
||||
| 内存上限 | `memory.max` | `resources.memory` |
|
||||
| 磁盘 IO | `io.max` | 预留,暂未绑定 |
|
||||
|
||||
## 执行流程
|
||||
|
||||
1. `Executor` 接收渲染后的脚本路径
|
||||
2. 根据 `PrebakeConfig` 创建 cgroup 限制(如启用)
|
||||
3. `std::process::Command` 启动子进程
|
||||
4. 实时捕获 stdout/stderr
|
||||
5. 超时或完成后,收集退出码与输出
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `cgroups.rs` 仅在 Linux 上生效,非 Linux 目标编译时相关代码被条件编译排除
|
||||
- `upm.rs` 将用户包管理器调用委托给子进程,不直接管理包状态
|
||||
- `repology.rs` 通过 HTTP 查询 Repology API 将通用包名映射到发行版特定名称
|
||||
@@ -0,0 +1,60 @@
|
||||
# 错误处理模式
|
||||
|
||||
Baker 采用 `thiserror` + `anyhow` 的组合策略处理错误。
|
||||
|
||||
## 选取原则
|
||||
|
||||
| 场景 | 使用 | 原因 |
|
||||
|------|------|------|
|
||||
| 库代码(types/、bake/、engine/) | `thiserror` | 结构化错误,调用方需要 match 处理 |
|
||||
| 二进制入口(main.rs) | `anyhow` | 统一包装,快速传播 |
|
||||
| 测试代码 | `anyhow::Result` | 减少样板,失败即 panic |
|
||||
|
||||
## thiserror 示例
|
||||
|
||||
```rust
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum BakeError {
|
||||
#[error("Decorator parse error @{decorator} (line {line_num}): {reason}")]
|
||||
DecoratorParseError {
|
||||
decorator: String,
|
||||
line_num: usize,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("Circular dependency: {0:?}")]
|
||||
CircularDependency(Vec<String>),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
}
|
||||
```
|
||||
|
||||
## anyhow 示例
|
||||
|
||||
```rust
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
fn read_config(path: &str) -> Result<String> {
|
||||
std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read config: {}", path))
|
||||
}
|
||||
```
|
||||
|
||||
## 退出码映射
|
||||
|
||||
实现 `HasExitCode` trait 将错误映射到标准退出码:
|
||||
|
||||
```rust
|
||||
impl HasExitCode for BakeError {
|
||||
fn exit_code(&self) -> i32 {
|
||||
match self {
|
||||
BakeError::DecoratorParseError { .. } => EXITCODE_PARSE_ERROR,
|
||||
BakeError::IoError(_) => EXITCODE_IO_ERROR,
|
||||
_ => EXITCODE_GENERAL_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1 +1,26 @@
|
||||
what
|
||||
# 卷3 开发者手册
|
||||
|
||||
## 项目简介
|
||||
|
||||
蜜饼工坊 (HoneyBiscuitWorkshop) 是一个 Rust 编写的 CI/CD 系统,支持 LLM 辅助的流水线自动配置。
|
||||
|
||||
## 代码结构
|
||||
|
||||
当前活跃维护的 crate:
|
||||
|
||||
| Crate | 说明 |
|
||||
|-------|------|
|
||||
| workshop-baker | 主编排器 (CLI + Daemon) |
|
||||
| workshop-vault | 密钥管理客户端 |
|
||||
| workshop-cert | TLS 证书生成器 |
|
||||
| workshop-deviceid | 设备 ID 库 |
|
||||
|
||||
其余模块(如 workshop-agent、workshop-llm-detector 等)已归档至 `archived/` 目录,不再活跃维护。
|
||||
|
||||
## 阅读指南
|
||||
|
||||
本卷面向开发者,涵盖架构、扩展和贡献指南。各章节内容如下:
|
||||
|
||||
- **架构**:整体架构、数据流、核心模块与插件系统
|
||||
- **扩展**:添加包管理器、自定义插件与 Decorator、模板语法
|
||||
- **贡献指南**:开发环境、代码规范、测试与提交流程
|
||||
@@ -0,0 +1,26 @@
|
||||
# 邮件模板
|
||||
|
||||
`MailConfig` 中的 `template` 字段支持 minijinja 语法,用于自定义邮件内容:
|
||||
|
||||
```yaml
|
||||
mail:
|
||||
smtp_host: "smtp.example.com"
|
||||
smtp_port: 587
|
||||
from: "ci@example.com"
|
||||
to: ["dev@example.com"]
|
||||
template: |
|
||||
Subject: [CI] {{PROJECT_NAME}} - {{BUILD_STATUS}}
|
||||
|
||||
项目: {{PROJECT_NAME}}
|
||||
分支: {{BRANCH}}
|
||||
提交: {{COMMIT_SHA}}
|
||||
状态: {{BUILD_STATUS}}
|
||||
|
||||
{% if BUILD_STATUS == "success" %}
|
||||
构建成功!
|
||||
{% else %}
|
||||
构建失败,请检查日志。
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
邮件模板中可使用所有 ExecutionContext 提供的模板变量。
|
||||
@@ -0,0 +1,74 @@
|
||||
# 插件系统指南
|
||||
|
||||
Baker 的插件系统基于 `Plugin` trait,支持四种运行时形态。
|
||||
|
||||
## Plugin Trait
|
||||
|
||||
```rust
|
||||
trait Plugin {
|
||||
async fn execute(&self, ctx: &ExecutionContext) -> Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
所有插件接收统一的 `ExecutionContext`,包含任务 ID、构建状态、环境变量与模板变量表。
|
||||
|
||||
## 内部插件
|
||||
|
||||
随 baker 二进制编译,直接调用 Rust 代码,无额外启动开销。
|
||||
|
||||
| 插件 | 文件 | 功能 |
|
||||
|------|------|------|
|
||||
| `mail` | `finalize/plugin/internal/mail.rs` | SMTP 邮件通知,支持 minijinja 模板 |
|
||||
| `webhook` | `finalize/plugin/internal/webhook.rs` | HTTP POST 回调 |
|
||||
| `satori` | `finalize/plugin/internal/satori.rs` | Satori 集成(占位) |
|
||||
| `insitenotify` | `finalize/plugin/internal/insitenotify.rs` | 站内通知(占位) |
|
||||
|
||||
内部插件配置位于 `finalize.yml` 的 `notification` 节点下,按数字键分组:
|
||||
|
||||
```yaml
|
||||
notification:
|
||||
1:
|
||||
mail:
|
||||
to: "admin@example.com"
|
||||
2:
|
||||
webhook:
|
||||
url: "https://hooks.example.com/ci"
|
||||
```
|
||||
|
||||
## 外部插件
|
||||
|
||||
### Shell 插件
|
||||
|
||||
可执行脚本,通过子进程调用。需在同级目录提供 `manifest.yml`:
|
||||
|
||||
```yaml
|
||||
name: "my-plugin"
|
||||
type: "shell"
|
||||
entry: "run.sh"
|
||||
```
|
||||
|
||||
### Dylib 插件
|
||||
|
||||
动态链接库,通过 `dlopen` 加载并调用约定符号的导出函数。
|
||||
|
||||
### Rhai 插件
|
||||
|
||||
使用 Rhai 脚本引擎执行 `.rhai` 文件(当前为 `todo!()` 占位,尚未实现)。
|
||||
|
||||
## 插件注册流程
|
||||
|
||||
1. `finalize.yml` 中声明插件配置
|
||||
2. `FinalizeStage::Plugin` 阶段遍历配置
|
||||
3. 根据 `type` 字段实例化对应插件类型
|
||||
4. 调用 `Plugin::execute()`,传入当前 `ExecutionContext`
|
||||
|
||||
## 模板上下文
|
||||
|
||||
所有插件共享同一模板变量表,由 `ExecutionContext` 提供:
|
||||
|
||||
| 命名空间 | 可用变量 |
|
||||
|----------|---------|
|
||||
| `build` | `status`, `id`, `duration`, `url`, `status_color` |
|
||||
| `pipeline` | `name` |
|
||||
| `commit` | `hash`, `author`, `author_email`, `message` |
|
||||
| `secret` | Vault 中存储的任意密钥(通过 `{{ secret.key }}` 引用) |
|
||||
@@ -0,0 +1,11 @@
|
||||
# PR 流程
|
||||
|
||||
提交 PR 前请确保通过所有检查:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy -- -D warnings
|
||||
cargo test
|
||||
```
|
||||
|
||||
然后提交并创建 Pull Request。CI 会自动运行完整测试套件。
|
||||
@@ -0,0 +1,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
-12
@@ -133,23 +133,12 @@ def run_baker(baker_bin, run_cmd):
|
||||
"""
|
||||
|
||||
def _run(subcommand, *args, cwd=None, env=None, timeout=120):
|
||||
"""Run workshop-baker with a subcommand.
|
||||
|
||||
Args:
|
||||
subcommand: Baker subcommand (prebake, bake, finalize).
|
||||
*args: Additional arguments to the subcommand.
|
||||
cwd: Working directory.
|
||||
env: Extra environment variables.
|
||||
timeout: Timeout in seconds.
|
||||
|
||||
Returns:
|
||||
dict with keys: stdout, stderr, returncode, success
|
||||
"""
|
||||
cmd = [
|
||||
baker_bin,
|
||||
"-u", "testuser",
|
||||
"-p", "test-pipeline",
|
||||
"-b", "test-build-001",
|
||||
"bare",
|
||||
subcommand,
|
||||
]
|
||||
cmd.extend(args)
|
||||
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
workspace:
|
||||
path: "/home/vulcan/workspace"
|
||||
fallback: true
|
||||
hooks:
|
||||
early:
|
||||
- name: "test-early-hook"
|
||||
command: "echo 'early hook executed'"
|
||||
late:
|
||||
- name: "test-late-hook"
|
||||
command: "echo 'late hook executed'"
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version: "0.5"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
@@ -0,0 +1,12 @@
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
workspace:
|
||||
path: "/home/vulcan/workspace"
|
||||
fallback: true
|
||||
custom: |
|
||||
#!/bin/sh
|
||||
echo 'custom bootstrap executed'
|
||||
mkdir -p /custom-workspace
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
workspace:
|
||||
path: "/home/vulcan/workspace"
|
||||
fallback: true
|
||||
dependencies:
|
||||
system:
|
||||
pacman:
|
||||
packages:
|
||||
- "which"
|
||||
user:
|
||||
cargo:
|
||||
- "ripgrep"
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
workspace:
|
||||
path: "/home/vulcan/workspace"
|
||||
fallback: true
|
||||
envvars:
|
||||
prebake:
|
||||
TEST_PREBAKE_VAR: "prebake_value"
|
||||
bake:
|
||||
TEST_BAKE_VAR: "bake_value"
|
||||
+2
@@ -10,6 +10,8 @@ hooks:
|
||||
early:
|
||||
- name: "test-early-hook"
|
||||
command: "echo 'early hook executed'"
|
||||
working_dir: "/workspace"
|
||||
late:
|
||||
- name: "test-late-hook"
|
||||
command: "echo 'late hook executed'"
|
||||
working_dir: "/workspace"
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
workspace:
|
||||
path: "/home/vulcan/workspace"
|
||||
fallback: true
|
||||
security:
|
||||
drop_after: "Bootstrap"
|
||||
+192
-25
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from probes import user, file, package
|
||||
@@ -7,6 +8,28 @@ from probes import user, file, package
|
||||
FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures", "prebake")
|
||||
|
||||
|
||||
@pytest.mark.prebake
|
||||
class TestPrebakeConfigValidation:
|
||||
|
||||
def test_invalid_version_fails(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "invalid_version.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert not result["success"], f"Expected prebake to fail with invalid version"
|
||||
assert "version" in result["stderr"].lower() or "Version" in result["stderr"]
|
||||
|
||||
def test_missing_required_field_fails(self, run_baker, work_dir):
|
||||
missing_env = os.path.join(work_dir, "missing_env.yml")
|
||||
with open(missing_env, "w") as f:
|
||||
f.write('version: "1.0"\n')
|
||||
result = run_baker("prebake", missing_env, cwd=work_dir, timeout=60)
|
||||
assert not result["success"]
|
||||
|
||||
def test_minimal_config_succeeds(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "minimal.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"], f"Minimal prebake failed: {result['stderr']}"
|
||||
|
||||
|
||||
@pytest.mark.prebake
|
||||
class TestPrebakeBootstrap:
|
||||
|
||||
@@ -20,7 +43,7 @@ class TestPrebakeBootstrap:
|
||||
config = os.path.join(FIXTURES, "bootstrap_only.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"]
|
||||
assert file.dir_exists("/home/vulcan/workspace") or file.dir_exists("/workspace")
|
||||
assert file.dir_exists("/home/vulcan/workspace")
|
||||
|
||||
def test_bootstrap_creates_symlink(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "bootstrap_only.yml")
|
||||
@@ -28,13 +51,6 @@ class TestPrebakeBootstrap:
|
||||
assert result["success"]
|
||||
assert os.path.islink("/workspace")
|
||||
|
||||
@pytest.mark.xfail(reason="Sudoers not created without package manager config", strict=False)
|
||||
def test_bootstrap_creates_sudoers(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "bootstrap_only.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"]
|
||||
assert file.file_exists("/etc/sudoers.d/workshop")
|
||||
|
||||
def test_bootstrap_generates_bootstrap_script(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "bootstrap_only.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
@@ -48,11 +64,90 @@ class TestPrebakeBootstrap:
|
||||
mode = file.file_permissions("/bootstrap.sh")
|
||||
assert mode & 0o111, f"/bootstrap.sh not executable, mode: {oct(mode)}"
|
||||
|
||||
def test_bootstrap_script_contains_user_creation(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "bootstrap_only.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"]
|
||||
assert file.file_exists("/bootstrap.sh")
|
||||
assert file.file_contains("/bootstrap.sh", "useradd")
|
||||
|
||||
def test_bootstrap_script_contains_workspace_setup(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "bootstrap_only.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"]
|
||||
assert file.file_contains("/bootstrap.sh", "mkdir -p /home/vulcan/workspace")
|
||||
|
||||
def test_bootstrap_with_pacman_creates_sudoers(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "with_pacman.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=120)
|
||||
assert result["success"], f"prebake failed: {result['stderr']}"
|
||||
assert file.file_exists("/etc/sudoers.d/workshop")
|
||||
|
||||
def test_custom_bootstrap_executes(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "with_custom_bootstrap.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"], f"Custom bootstrap failed: {result['stderr']}"
|
||||
assert file.dir_exists("/custom-workspace")
|
||||
|
||||
|
||||
@pytest.mark.prebake
|
||||
class TestPrebakeDeps:
|
||||
class TestPrebakeDryRun:
|
||||
|
||||
@pytest.mark.xfail(reason="Package installation may fail in test environment")
|
||||
def test_dry_run_does_not_create_user(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "bootstrap_only.yml")
|
||||
env = {"HBW_DRY_RUN": "true"}
|
||||
result = run_baker("prebake", config, cwd=work_dir, env=env, timeout=60)
|
||||
assert result["success"], f"Dry-run failed: {result['stderr']}"
|
||||
|
||||
def test_dry_run_logs_script_content(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "bootstrap_only.yml")
|
||||
env = {"HBW_DRY_RUN": "true", "RUST_LOG": "info"}
|
||||
result = run_baker("prebake", config, cwd=work_dir, env=env, timeout=60)
|
||||
assert result["success"]
|
||||
combined = result["stdout"] + result["stderr"]
|
||||
assert "DRY_RUN" in combined or "dry_run" in combined.lower()
|
||||
|
||||
|
||||
@pytest.mark.prebake
|
||||
class TestPrebakeHooks:
|
||||
|
||||
#@pytest.mark.xfail(reason="Hook execution fails with Permission denied in container environment", strict=False)
|
||||
def test_early_hook_executes(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "with_hooks.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"], f"prebake with hooks failed: {result['stderr']}"
|
||||
combined = result["stdout"] + result["stderr"]
|
||||
assert "early hook executed" in combined
|
||||
|
||||
#@pytest.mark.xfail(reason="Hook execution fails with Permission denied in container environment", strict=False)
|
||||
def test_late_hook_executes(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "with_hooks.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"], f"prebake with hooks failed: {result['stderr']}"
|
||||
combined = result["stdout"] + result["stderr"]
|
||||
assert "late hook executed" in combined
|
||||
|
||||
#@pytest.mark.xfail(reason="Hook execution fails with Permission denied in container environment", strict=False)
|
||||
def test_hooks_run_in_order(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "with_hooks.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"]
|
||||
combined = result["stdout"] + result["stderr"]
|
||||
early_pos = combined.find("early hook executed")
|
||||
late_pos = combined.find("late hook executed")
|
||||
assert early_pos != -1 and late_pos != -1
|
||||
assert early_pos < late_pos, "Early hook should execute before late hook"
|
||||
|
||||
def test_empty_hooks_succeed(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "minimal.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"], f"prebake with no hooks failed: {result['stderr']}"
|
||||
|
||||
|
||||
@pytest.mark.prebake
|
||||
class TestPrebakeDepsSystem:
|
||||
|
||||
@pytest.mark.xfail(reason="Package installation may fail in test environment", strict=False)
|
||||
def test_pacman_packages_installed(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "with_pacman.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=120)
|
||||
@@ -60,23 +155,59 @@ class TestPrebakeDeps:
|
||||
assert package.package_installed("curl")
|
||||
assert package.package_installed("wget")
|
||||
|
||||
def test_pacman_mirror_change(self, run_baker, work_dir):
|
||||
mirror_config = os.path.join(work_dir, "mirror.yml")
|
||||
with open(mirror_config, "w") as f:
|
||||
f.write('''
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
dependencies:
|
||||
system:
|
||||
pacman:
|
||||
packages: ["which"]
|
||||
mirror: "https://mirrors.tuna.tsinghua.edu.cn/archlinux/$repo/os/$arch"
|
||||
''')
|
||||
result = run_baker("prebake", mirror_config, cwd=work_dir, timeout=120)
|
||||
assert result["success"], f"prebake with mirror failed: {result['stderr']}"
|
||||
|
||||
|
||||
@pytest.mark.prebake
|
||||
class TestPrebakeHooks:
|
||||
class TestPrebakeEnvVars:
|
||||
|
||||
@pytest.mark.xfail(reason="Late hook fails after privilege drop to vulcan (Permission denied)", strict=False)
|
||||
def test_early_hook_executes(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "with_hooks.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"], f"prebake with hooks failed: stdout={result['stdout']!r} stderr={result['stderr']!r}"
|
||||
assert "early hook executed" in result["stdout"] or "early hook executed" in result["stderr"]
|
||||
def test_prebake_envvars_injected(self, run_baker, work_dir):
|
||||
hook_config = os.path.join(work_dir, "envvar_hook.yml")
|
||||
with open(hook_config, "w") as f:
|
||||
f.write('''
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: "baremetal"
|
||||
bootstrap:
|
||||
user: "vulcan"
|
||||
envvars:
|
||||
prebake:
|
||||
TEST_INJECTED: "injected_value"
|
||||
hooks:
|
||||
early:
|
||||
- name: "check-env"
|
||||
command: "echo $TEST_INJECTED"
|
||||
''')
|
||||
result = run_baker("prebake", hook_config, cwd=work_dir, timeout=60)
|
||||
assert result["success"], f"prebake with envvars failed: {result['stderr']}"
|
||||
combined = result["stdout"] + result["stderr"]
|
||||
assert "injected_value" in combined
|
||||
|
||||
@pytest.mark.xfail(reason="Late hook fails after privilege drop to vulcan (Permission denied)", strict=False)
|
||||
def test_late_hook_executes(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "with_hooks.yml")
|
||||
|
||||
@pytest.mark.prebake
|
||||
class TestPrebakeSecurity:
|
||||
|
||||
#@pytest.mark.xfail(reason="Privilege drop may fail in container without proper setup", strict=False)
|
||||
def test_privilege_drop_after_bootstrap(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "with_security.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"], f"prebake with hooks failed: stdout={result['stdout']!r} stderr={result['stderr']!r}"
|
||||
assert "late hook executed" in result["stdout"] or "late hook executed" in result["stderr"]
|
||||
assert result["success"], f"prebake with security failed: {result['stderr']}"
|
||||
|
||||
|
||||
@pytest.mark.prebake
|
||||
@@ -88,7 +219,43 @@ class TestPrebakeStatus:
|
||||
assert result["success"]
|
||||
assert os.path.exists("/tmp/.hbwstatus")
|
||||
|
||||
def test_minimal_config_succeeds(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "minimal.yml")
|
||||
def test_status_contains_bootstrap_stage(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "bootstrap_only.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"], f"Minimal prebake failed: {result['stderr']}"
|
||||
assert result["success"]
|
||||
with open("/tmp/.hbwstatus", "r") as f:
|
||||
lines = f.readlines()
|
||||
assert len(lines) > 0
|
||||
bootstrap_lines = [l for l in lines if json.loads(l).get("name") == "bootstrap"]
|
||||
assert len(bootstrap_lines) > 0, "No bootstrap stage found in status file"
|
||||
data = json.loads(bootstrap_lines[-1])
|
||||
assert data.get("phase") == "Prebake"
|
||||
assert data.get("result") == "Success"
|
||||
|
||||
def test_status_contains_duration(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "bootstrap_only.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"]
|
||||
with open("/tmp/.hbwstatus", "r") as f:
|
||||
data = json.loads(f.readline())
|
||||
assert "duration_ms" in data
|
||||
assert isinstance(data["duration_ms"], int)
|
||||
|
||||
def test_status_contains_timestamps(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "bootstrap_only.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=60)
|
||||
assert result["success"]
|
||||
with open("/tmp/.hbwstatus", "r") as f:
|
||||
data = json.loads(f.readline())
|
||||
assert "started_at" in data
|
||||
assert "finished_at" in data
|
||||
|
||||
def test_status_written_for_each_stage(self, run_baker, work_dir):
|
||||
config = os.path.join(FIXTURES, "with_pacman.yml")
|
||||
result = run_baker("prebake", config, cwd=work_dir, timeout=120)
|
||||
assert result["success"], f"prebake failed: {result['stderr']}"
|
||||
with open("/tmp/.hbwstatus", "r") as f:
|
||||
lines = f.readlines()
|
||||
names = [json.loads(line).get("name") for line in lines]
|
||||
assert "bootstrap" in names
|
||||
assert "depssystem" in names
|
||||
|
||||
Generated
+32
@@ -2155,6 +2155,19 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "globset"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"bstr",
|
||||
"log",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.12"
|
||||
@@ -5254,6 +5267,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"gix",
|
||||
"glob",
|
||||
"globset",
|
||||
"lazy_static",
|
||||
"lettre",
|
||||
"libc",
|
||||
@@ -5276,9 +5290,27 @@ dependencies = [
|
||||
"topological-sort",
|
||||
"uuid",
|
||||
"which",
|
||||
"workshop-engine",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "workshop-engine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"duration-str",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"os_info",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.2"
|
||||
|
||||
@@ -20,6 +20,7 @@ name = "engine_integration"
|
||||
path = "tests/engine_integration.rs"
|
||||
|
||||
[dependencies]
|
||||
workshop-engine = { path = "../workshop-engine" }
|
||||
anyhow = "1.0.100"
|
||||
bollard = { version = "0.20.0", features = ["buildkit", "chrono", "ssh"] }
|
||||
cgroups-rs = "0.5.0"
|
||||
@@ -56,6 +57,7 @@ gix = "0.81.0"
|
||||
sha2 = "0.10"
|
||||
flate2 = "1.0"
|
||||
zstd = "0.13"
|
||||
globset = "0.4.18"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = "0.5"
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
use crate::ExecutionContext;
|
||||
use workshop_engine::{Engine, EventSender, ExecutionContext};
|
||||
use crate::bake::parser::Function;
|
||||
use crate::bake::constant::{DEFAULT_WORKSPACE, TRIVIAL_SCRIPT_NAME};
|
||||
use crate::bake::error::BakeError;
|
||||
use crate::cli::Cli;
|
||||
use crate::constant::{DEFAULT_WORKSPACE, STANDALONE_STATUS_DIR, TRIVIAL_SCRIPT_NAME};
|
||||
use crate::engine::EventSender;
|
||||
use crate::error::BakeError;
|
||||
use crate::prebake::PrebakeConfig;
|
||||
use crate::prebake::security::get_drop_after;
|
||||
use crate::prebake::stage::PrebakeStage;
|
||||
use crate::types::buildstatus::{StageInfo, StagePhase, StageResult, write_stage_status};
|
||||
use crate::{Engine, prebake};
|
||||
use chrono::Utc;
|
||||
use crate::prebake;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod builder;
|
||||
pub mod constant;
|
||||
mod decorator;
|
||||
pub mod parser;
|
||||
pub mod error;
|
||||
mod schedule;
|
||||
|
||||
pub async fn bake(
|
||||
@@ -51,7 +50,6 @@ pub async fn bake(
|
||||
}
|
||||
|
||||
if !has_pipeline || trivial {
|
||||
let stage_start = Utc::now();
|
||||
let function = Function {
|
||||
name: TRIVIAL_SCRIPT_NAME.to_string(),
|
||||
decorators: vec![],
|
||||
@@ -60,32 +58,11 @@ pub async fn bake(
|
||||
let script =
|
||||
builder::build_script(&function, bake_base_path, &workspace, None, use_template)?;
|
||||
let result = engine.execute_script(&script, &ctx, &event_tx).await;
|
||||
let stage_result = if result.is_ok() {
|
||||
StageResult::Success
|
||||
} else {
|
||||
StageResult::Failure
|
||||
};
|
||||
let _ = write_stage_status(
|
||||
STANDALONE_STATUS_DIR,
|
||||
StageInfo {
|
||||
name: "trivial".to_string(),
|
||||
phase: StagePhase::Bake,
|
||||
substage: "trivial".to_string(),
|
||||
result: stage_result,
|
||||
duration_ms: Utc::now()
|
||||
.signed_duration_since(stage_start)
|
||||
.num_milliseconds() as u64,
|
||||
started_at: stage_start,
|
||||
finished_at: Utc::now(),
|
||||
error_message: result.as_ref().err().map(|e| e.to_string()),
|
||||
},
|
||||
);
|
||||
result?;
|
||||
} else {
|
||||
let sorted_functions = schedule::sort_function(functions.pipeline_functions)?;
|
||||
let remaining = functions.remaining_code;
|
||||
for func in sorted_functions {
|
||||
let stage_start = Utc::now();
|
||||
let mut modified_func = func.clone();
|
||||
modified_func.body = remaining.clone() + &func.body;
|
||||
let script = builder::build_script(
|
||||
@@ -96,26 +73,6 @@ pub async fn bake(
|
||||
use_template,
|
||||
)?;
|
||||
let result = engine.execute_script(&script, &ctx, &event_tx).await;
|
||||
let stage_result = if result.is_ok() {
|
||||
StageResult::Success
|
||||
} else {
|
||||
StageResult::Failure
|
||||
};
|
||||
let _ = write_stage_status(
|
||||
STANDALONE_STATUS_DIR,
|
||||
StageInfo {
|
||||
name: func.name.clone(),
|
||||
phase: StagePhase::Bake,
|
||||
substage: func.name.clone(),
|
||||
result: stage_result,
|
||||
duration_ms: Utc::now()
|
||||
.signed_duration_since(stage_start)
|
||||
.num_milliseconds() as u64,
|
||||
started_at: stage_start,
|
||||
finished_at: Utc::now(),
|
||||
error_message: result.as_ref().err().map(|e| e.to_string()),
|
||||
},
|
||||
);
|
||||
result?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::bake::decorator::Decorator;
|
||||
use crate::bake::parser::Function;
|
||||
use crate::constant::TRIVIAL_SCRIPT_NAME;
|
||||
use crate::bake::constant::{PIPELINE_SKIP_ERRORCODE, TRIVIAL_SCRIPT_NAME};
|
||||
use crate::bake::error::BakeError;
|
||||
use minijinja::{Environment, context};
|
||||
use std::fs::Permissions;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -8,12 +9,11 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
use crate::constant::PIPELINE_SKIP_ERRORCODE;
|
||||
use crate::error::BakeError;
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -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;
|
||||
@@ -3,7 +3,7 @@ use regex::Regex;
|
||||
use std::fmt::Display;
|
||||
use std::fmt::Formatter;
|
||||
|
||||
use crate::error::BakeError;
|
||||
use crate::bake::error::BakeError;
|
||||
|
||||
lazy_static! {
|
||||
// "# @decorator" or "# @decorator(parameters)"
|
||||
@@ -15,20 +15,34 @@ lazy_static! {
|
||||
static ref INVALID_ARGUMENT: &'static str = "invalid argument";
|
||||
}
|
||||
|
||||
/// Pipeline decorator representing shell script annotations in `# @name(args)` syntax.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Decorator {
|
||||
Unknown, // for general error reporting when the decorator name is not recognized
|
||||
/// Unrecognized decorator name (used for error reporting).
|
||||
Unknown,
|
||||
/// Marks a function as a main pipeline step.
|
||||
Pipeline,
|
||||
/// Conditional execution with shell condition.
|
||||
If(String),
|
||||
/// Allows the step to fail without halting the pipeline.
|
||||
Fallible,
|
||||
/// Repeat the function body N times.
|
||||
Loop(usize),
|
||||
/// Run after the specified function completes.
|
||||
After(String),
|
||||
/// Execution timeout in seconds.
|
||||
Timeout(u32),
|
||||
/// Retry N times with M second delay between attempts.
|
||||
Retry(usize, u32),
|
||||
/// Export environment variables to subsequent steps.
|
||||
Export,
|
||||
/// Chain functions via pipe with comma-separated names.
|
||||
Pipe(Vec<String>),
|
||||
/// Run concurrently with other parallel functions.
|
||||
Parallel,
|
||||
/// Run as a background daemon process.
|
||||
Daemon,
|
||||
/// Health check endpoint for daemon verification.
|
||||
Health(String),
|
||||
}
|
||||
|
||||
@@ -52,6 +66,8 @@ impl Display for Decorator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::decorator::{Decorator, parse_decorator};
|
||||
use crate::bake::BakeError;
|
||||
use crate::bake::error::BakeError;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use shlex::Shlex;
|
||||
@@ -12,14 +12,19 @@ lazy_static! {
|
||||
static ref DECORATOR_HEADER_REGEX: Regex = Regex::new(r"\s*#\s+@").unwrap();
|
||||
}
|
||||
|
||||
/// A parsed shell function with decorator annotations and body content.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Function {
|
||||
/// Function name (alphanumeric with underscore/hyphen).
|
||||
pub name: String,
|
||||
/// Decorators applied to this function.
|
||||
pub decorators: Vec<Decorator>,
|
||||
/// Raw function body including `function name() { ... }` syntax.
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl Function {
|
||||
/// Finds the first decorator matching the given predicate.
|
||||
pub fn find_decorator<T>(&self, matcher: impl Fn(&Decorator) -> Option<T>) -> Option<T> {
|
||||
self.decorators.iter().find_map(matcher)
|
||||
}
|
||||
@@ -37,12 +42,17 @@ enum ParserState {
|
||||
},
|
||||
}
|
||||
|
||||
/// Result of parsing a shell script with decorator annotations.
|
||||
#[derive(Debug)]
|
||||
pub struct ParsedScript {
|
||||
/// Functions marked with `@pipeline` decorator.
|
||||
pub pipeline_functions: Vec<Function>,
|
||||
/// Code not belonging to any pipeline function (comments, helpers, etc.).
|
||||
pub remaining_code: String,
|
||||
}
|
||||
|
||||
/// Parses shell scripts with `# @decorator(args)` annotations into a `Function` list.
|
||||
/// Lines prefixed with `# @` before a function definition become decorators for that function.
|
||||
pub fn parse_script(text: &str) -> Result<ParsedScript, BakeError> {
|
||||
let mut pipeline_functions = Vec::new();
|
||||
let mut remaining_code = String::new();
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use super::{decorator::Decorator, parser::Function};
|
||||
use crate::error::BakeError;
|
||||
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()
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
pub const DEFAULT_WORKSPACE: &str = "/workspace";
|
||||
pub const BOOTSTRAP_SCRIPT_PATH: &str = "/bootstrap.sh";
|
||||
pub const BAKE_SCRIPT_PATH: &str = "bake.sh";
|
||||
pub const PIPELINE_SKIP_ERRORCODE: u8 = 233;
|
||||
pub const TRIVIAL_SCRIPT_NAME: &str = "BAKE_TRIVIAL";
|
||||
pub const FINALIZE_PLUGIN_MANIFEST: &str = "manifest";
|
||||
pub const FINALIZE_PLUGIN_MANIFEST_EXTENSION: &[&str] = &[".yml", ".yaml"];
|
||||
pub const FINALIZE_SHELL_ENVPREFIX: &str = "HBW_ARG";
|
||||
pub const FINALIZE_STANDALONE_PLUGIN_PATH: &str = "/tmp/baker/plugin";
|
||||
|
||||
/// Standalone mode build status file directory
|
||||
pub const STANDALONE_STATUS_DIR: &str = "/tmp";
|
||||
/// Standalone mode build status file name
|
||||
pub const STANDALONE_STATUS_FILE: &str = ".hbwstatus";
|
||||
@@ -1,17 +0,0 @@
|
||||
//! Engine module - execution engine for workshop-baker
|
||||
//!
|
||||
//! This module provides the core execution functionality for running commands
|
||||
//! in isolated environments with resource management.
|
||||
|
||||
pub mod cgroups;
|
||||
pub mod executor;
|
||||
pub mod pm;
|
||||
pub mod repology;
|
||||
pub mod types;
|
||||
pub mod upm;
|
||||
|
||||
// Re-export commonly used types for convenience
|
||||
pub use types::{
|
||||
Engine, EventSender, ExecutionContext, ExecutionError, ExecutionEvent, ExecutionResult,
|
||||
ResourceLimits, ResourceUsage, StreamType,
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
# workshop-baker/src/engine
|
||||
|
||||
**Generated:** 2026-04-22
|
||||
**Commit:** 7b3b71a
|
||||
|
||||
Execution runtime with resource isolation and package management.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
engine/
|
||||
├── executor.rs # Script execution with cgroups, pipes, exit codes
|
||||
├── cgroups.rs # Linux cgroup resource limits (memory, CPU, IO)
|
||||
├── types.rs # ResourceLimits, ExecutionResult, ProcessConfig
|
||||
├── pm.rs # Package manager detection (apt, pacman, etc.)
|
||||
├── pm/ # PM implementations
|
||||
│ ├── apt.rs # Debian/Ubuntu (WIP - todo!() stubs)
|
||||
│ └── pacman.rs # Arch Linux
|
||||
├── upm.rs # User package managers (Nix/Guix)
|
||||
├── upm/custom.rs # Custom PM support (unimplemented)
|
||||
└── repology.rs # Repology API for package name resolution
|
||||
└── local.rs # Python-based local repology (non-standard)
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Task | Location |
|
||||
|------|----------|
|
||||
| Execute script with limits | `executor.rs:Executor::execute_script()` |
|
||||
| Resource limits | `types.rs:56` - `ResourceLimits` struct |
|
||||
| Cgroup management | `cgroups.rs` - CgroupManager v1/v2 |
|
||||
| Package detection | `pm.rs:detection()` |
|
||||
| Repology lookup | `repology.rs` - RepologyEndpoint |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **Executor**: Lightweight wrapper around CgroupManager for isolation
|
||||
- **ResourceLimits**: Memory, CPU, IO bounds passed to cgroups
|
||||
- **Package Manager Detection**: Auto-detects from `/etc/os-release`
|
||||
- **WIP**: apt.rs has `todo!()` stubs (lines 22, 45, 50)
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
1. **Python in Rust** — `repology/local.rs` embeds Python repology client
|
||||
2. **Unsafe libc** — Multiple `unsafe { libc::geteuid() }` in security.rs (parent)
|
||||
3. **Unwrap heavy** — cgroups.rs has many `.unwrap()` calls
|
||||
@@ -1,46 +0,0 @@
|
||||
pub mod local;
|
||||
|
||||
use crate::types::repology::RepologyEndpoint;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RepologyError {
|
||||
Unimplemented,
|
||||
RemoteNotSupported,
|
||||
}
|
||||
|
||||
pub async fn resolve_package_names(
|
||||
packages: &[String],
|
||||
mapped: bool,
|
||||
endpoint: &RepologyEndpoint,
|
||||
target_pm: &str,
|
||||
) -> Result<HashMap<String, Vec<String>>, RepologyError> {
|
||||
let mut result = HashMap::new();
|
||||
|
||||
if packages.is_empty() {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
if mapped {
|
||||
result.insert(target_pm.to_string(), packages.to_vec());
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
match endpoint {
|
||||
RepologyEndpoint::Local | RepologyEndpoint::Default => {
|
||||
let resolved = local::resolve(packages, target_pm).await;
|
||||
if !resolved.is_empty() {
|
||||
result.insert(target_pm.to_string(), resolved);
|
||||
}
|
||||
}
|
||||
RepologyEndpoint::None => {
|
||||
result.insert(target_pm.to_string(), packages.to_vec());
|
||||
}
|
||||
RepologyEndpoint::Disabled => {}
|
||||
RepologyEndpoint::Remote | RepologyEndpoint::Server => {
|
||||
return Err(RepologyError::Unimplemented);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
use super::types::EventSender;
|
||||
use async_trait::async_trait;
|
||||
use serde_yaml::Value;
|
||||
mod custom;
|
||||
|
||||
use crate::{ExecutionContext, error::DependencyError};
|
||||
|
||||
pub struct UPMSysDeps {
|
||||
pub packages: Vec<String>,
|
||||
pub mapped: bool,
|
||||
}
|
||||
|
||||
impl UPMSysDeps {
|
||||
pub fn new(packages: Vec<String>, mapped: bool) -> Self {
|
||||
Self { packages, mapped }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserPackageManager {
|
||||
/// Name of package manager
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Indicates that DepsSystem needs to install these packages
|
||||
fn system_dependency(&self, config: &Value) -> UPMSysDeps;
|
||||
|
||||
/// Main function of User Package Manager
|
||||
async fn main(
|
||||
&self,
|
||||
config: &Value,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
) -> Result<(), DependencyError>;
|
||||
}
|
||||
|
||||
pub fn all_managers() -> Vec<Box<dyn UserPackageManager>> {
|
||||
vec![Box::new(custom::Custom)]
|
||||
}
|
||||
|
||||
pub fn select(manager: &str) -> Option<Box<dyn UserPackageManager>> {
|
||||
all_managers().into_iter().find(|pm| pm.name() == manager)
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
use os_info::Info;
|
||||
use std::fmt::Debug;
|
||||
use std::fmt::Display;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
// Obsolete?
|
||||
pub trait HasExitCode: Display + Debug + Send + Sync + Sized + 'static {
|
||||
fn exit_code(&self) -> i32;
|
||||
}
|
||||
|
||||
pub const EXITCODE_OK: i32 = 0;
|
||||
pub const EXITCODE_GENERAL_ERROR: i32 = 1;
|
||||
pub const EXITCODE_INVALID_ARGS: i32 = 2;
|
||||
pub const EXITCODE_IO_ERROR: i32 = 3;
|
||||
pub const EXITCODE_PARSE_ERROR: i32 = 4;
|
||||
pub const EXITCODE_EXECUTION_ERROR: i32 = 5;
|
||||
pub const EXITCODE_TIMEOUT: i32 = 124;
|
||||
pub const EXITCODE_PRIV_DROP_FAILED: i32 = 201;
|
||||
|
||||
#[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),
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PrebakeError {
|
||||
#[error("Privilege drop failed: {0}")]
|
||||
PrivDropFailed(String),
|
||||
|
||||
#[error("User not found: {0}")]
|
||||
UserNotFound(String),
|
||||
|
||||
#[error("Invalid privilege state: current_uid={current_uid}, target_uid={target_uid}")]
|
||||
InvalidPrivilegeState { current_uid: u32, target_uid: u32 },
|
||||
|
||||
#[error("Invalid stage: current={current}, expected={expected}")]
|
||||
InvalidStage { current: String, expected: String },
|
||||
|
||||
#[error("Failed to validate prebake.yml.")]
|
||||
ValidateError(),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
#[error("YAML parse error: {0}")]
|
||||
YamlParseError(#[from] serde_yaml::Error),
|
||||
|
||||
#[error("Bad prebake.yml: {0}")]
|
||||
ConfigError(String),
|
||||
}
|
||||
|
||||
impl HasExitCode for PrebakeError {
|
||||
fn exit_code(&self) -> i32 {
|
||||
match self {
|
||||
PrebakeError::PrivDropFailed(_) => EXITCODE_PRIV_DROP_FAILED,
|
||||
PrebakeError::UserNotFound(_) => EXITCODE_PRIV_DROP_FAILED,
|
||||
PrebakeError::InvalidPrivilegeState { .. } => EXITCODE_PRIV_DROP_FAILED,
|
||||
PrebakeError::ValidateError() => EXITCODE_PARSE_ERROR,
|
||||
PrebakeError::InvalidStage { .. } => EXITCODE_PRIV_DROP_FAILED,
|
||||
PrebakeError::IoError(_) => EXITCODE_IO_ERROR,
|
||||
PrebakeError::YamlParseError(_) => EXITCODE_PARSE_ERROR,
|
||||
PrebakeError::ConfigError(_) => EXITCODE_PARSE_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum BootstrapError {
|
||||
#[error("Failed to write bootstrap script: {0}")]
|
||||
IOError(#[from] std::io::Error),
|
||||
|
||||
#[error("Failed to find bootstrap.sh: {0}")]
|
||||
ScriptNotFound(PathBuf),
|
||||
|
||||
#[error("Failed to execute bootstrap.sh: {0}")]
|
||||
ExecutionError(#[from] ExecutionError),
|
||||
|
||||
#[error("Failed to create user {0}: {1}")]
|
||||
UserCreationFailed(String, String),
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DependencyError {
|
||||
#[error("No supported package manager found for {0}")]
|
||||
UnsupportedPlatform(Info),
|
||||
|
||||
#[error("Unknown package manager: {0}")]
|
||||
UnknownManager(String),
|
||||
|
||||
#[error("Failed to parse config")]
|
||||
ParseError(),
|
||||
|
||||
#[error("Stage {stage} failed: {source}")]
|
||||
StageFailed {
|
||||
stage: &'static str,
|
||||
#[source]
|
||||
source: ExecutionError,
|
||||
},
|
||||
|
||||
#[error("Custom command execution failed: {0}")]
|
||||
CustomFailed(#[from] ExecutionError),
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ExecutionError {
|
||||
#[error("Invalid command: {0}")]
|
||||
InvalidCommand(String),
|
||||
|
||||
#[error("Execution failed: {0}")]
|
||||
ExecutionFailed(String),
|
||||
|
||||
#[error("Permission denied: uid={euid}, gid={egid}: {reason}")]
|
||||
PermissionDenied {
|
||||
euid: u32,
|
||||
egid: u32,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("Timeout")]
|
||||
Timeout,
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
IoError(String),
|
||||
|
||||
#[error("Script not found: {0}")]
|
||||
ScriptNotFound(PathBuf),
|
||||
|
||||
#[error("Package manager not found")]
|
||||
PackageManagerNotFound,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HasExitCode for ExecutionError {
|
||||
fn exit_code(&self) -> i32 {
|
||||
match self {
|
||||
ExecutionError::InvalidCommand(_) => EXITCODE_EXECUTION_ERROR,
|
||||
ExecutionError::ExecutionFailed(_) => EXITCODE_EXECUTION_ERROR,
|
||||
ExecutionError::PermissionDenied { .. } => EXITCODE_GENERAL_ERROR,
|
||||
ExecutionError::Timeout => EXITCODE_TIMEOUT,
|
||||
ExecutionError::IoError(_) => EXITCODE_IO_ERROR,
|
||||
ExecutionError::ScriptNotFound(_) => EXITCODE_INVALID_ARGS,
|
||||
ExecutionError::PackageManagerNotFound => EXITCODE_GENERAL_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,17 @@
|
||||
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::constant::FINALIZE_STANDALONE_PLUGIN_PATH;
|
||||
use crate::constant::STANDALONE_STATUS_DIR;
|
||||
use crate::engine::EventSender;
|
||||
use crate::finalize::constant::FINALIZE_STANDALONE_PLUGIN_PATH;
|
||||
use workshop_engine::EventSender;
|
||||
use crate::finalize::plugin::fetch::{self, FetchArgument};
|
||||
use crate::types::buildstatus::{BuildStatus, StageResult, read_build_status};
|
||||
use crate::types::buildstatus::{StageInfo, StagePhase};
|
||||
use chrono::Utc;
|
||||
use std::path::PathBuf;
|
||||
// use crate::finalize::error::PluginError;
|
||||
pub use config::*;
|
||||
pub use error::FinalizeError;
|
||||
use std::path::Path;
|
||||
@@ -28,7 +25,7 @@ pub fn parse(path: &Path) -> Result<FinalizeConfig, FinalizeError> {
|
||||
pub async fn finalize(
|
||||
finalize_path: &Path,
|
||||
cli: &Cli,
|
||||
ctx: &mut crate::ExecutionContext,
|
||||
ctx: &mut workshop_engine::ExecutionContext,
|
||||
event_tx: EventSender,
|
||||
) -> Result<(), FinalizeError> {
|
||||
let mut finalize = parse(finalize_path)?;
|
||||
@@ -76,13 +73,9 @@ pub async fn finalize(
|
||||
)
|
||||
.await;
|
||||
|
||||
// Read build status from prior stages
|
||||
let build_status = read_build_status(STANDALONE_STATUS_DIR).unwrap_or_default();
|
||||
let final_result = build_status.final_result();
|
||||
|
||||
// Determine if we should continue with artifacts/latehook
|
||||
// If prior stages failed, we still send notification but skip artifacts
|
||||
let prior_failed = build_status.has_failures() || earlyhook_result.is_err();
|
||||
let prior_failed = earlyhook_result.is_err();
|
||||
|
||||
// Prepare artifacts
|
||||
// TODO: artifact stage implementation
|
||||
@@ -96,20 +89,5 @@ pub async fn finalize(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Write finalize success status
|
||||
let _ = crate::types::buildstatus::write_stage_status(
|
||||
STANDALONE_STATUS_DIR,
|
||||
StageInfo {
|
||||
name: "finalize".to_string(),
|
||||
phase: StagePhase::Finalize,
|
||||
substage: "clean".to_string(),
|
||||
result: StageResult::Success,
|
||||
duration_ms: 0,
|
||||
started_at: Utc::now(),
|
||||
finished_at: Utc::now(),
|
||||
error_message: None,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
//! This module defines the configuration schema for the finalize stage,
|
||||
//! which handles artifact collection, publishing, and notifications.
|
||||
|
||||
use crate::types::command::CustomCommand;
|
||||
use crate::types::compression::CompressionMethod;
|
||||
use crate::types::time::deserialize_duration_ms;
|
||||
use workshop_engine::{CustomCommand, deserialize_duration_ms};
|
||||
use crate::finalize::types::compression::CompressionMethod;
|
||||
use semver::{Version, VersionReq};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml::Value;
|
||||
@@ -180,7 +179,7 @@ pub struct NotificationTemplateDef {
|
||||
}
|
||||
|
||||
/// Notification trigger type.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Trigger {
|
||||
OnSuccess,
|
||||
OnFailure,
|
||||
|
||||
@@ -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";
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
use workshop_engine::{
|
||||
ExecutionError,
|
||||
error::{
|
||||
EXITCODE_EXECUTION_ERROR, EXITCODE_GENERAL_ERROR, EXITCODE_IO_ERROR, EXITCODE_PARSE_ERROR,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::engine::{ExecutionEvent, StreamType};
|
||||
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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::constant::{FINALIZE_PLUGIN_MANIFEST, FINALIZE_PLUGIN_MANIFEST_EXTENSION};
|
||||
use crate::finalize::constant::{FINALIZE_PLUGIN_MANIFEST, FINALIZE_PLUGIN_MANIFEST_EXTENSION};
|
||||
use crate::finalize::RawPluginMap;
|
||||
use crate::finalize::plugin::metadata::{PluginMetadata, PluginType};
|
||||
use crate::{EventSender, ExecutionContext, finalize::error::PluginError};
|
||||
@@ -16,7 +16,7 @@ mod shell;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FinalizePlugin {
|
||||
metadata: PluginMetadata,
|
||||
pub metadata: PluginMetadata,
|
||||
argument: serde_yaml::Value,
|
||||
pub entry: Arc<dyn AsyncPluginFn>,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
use super::types::ChecksumType;
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
|
||||
/// Detects the checksum type based on the length of the checksum string.
|
||||
///
|
||||
/// Returns `Ok(None)` for empty string (checksum disabled).
|
||||
/// Returns `Ok(Some(ChecksumType))` for valid SHA256 (64 chars) or SHA512 (128 chars).
|
||||
/// Returns `Err` for invalid lengths (32=MD5, 40=SHA1) or unsupported lengths.
|
||||
pub fn detect_checksum_type(checksum: &str) -> Result<Option<ChecksumType>, String> {
|
||||
match checksum.len() {
|
||||
0 => Ok(None),
|
||||
@@ -23,6 +28,10 @@ pub fn detect_checksum_type(checksum: &str) -> Result<Option<ChecksumType>, Stri
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies that the data matches the expected checksum.
|
||||
///
|
||||
/// Compares the computed hash of `data` against `expected` using the specified `checksum_type`.
|
||||
/// Returns `Ok(())` if the checksums match, `Err` otherwise.
|
||||
pub fn verify_checksum(
|
||||
data: &[u8],
|
||||
expected: &str,
|
||||
@@ -51,3 +60,78 @@ pub fn verify_checksum(
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_empty() {
|
||||
assert_eq!(detect_checksum_type("").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_md5_rejected() {
|
||||
let result = detect_checksum_type("a".repeat(32).as_str());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("MD5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_sha1_rejected() {
|
||||
let result = detect_checksum_type("a".repeat(40).as_str());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("SHA1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_sha256() {
|
||||
assert_eq!(
|
||||
detect_checksum_type("a".repeat(64).as_str()).unwrap(),
|
||||
Some(ChecksumType::Sha256)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_sha512() {
|
||||
assert_eq!(
|
||||
detect_checksum_type("a".repeat(128).as_str()).unwrap(),
|
||||
Some(ChecksumType::Sha512)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_invalid_length() {
|
||||
let result = detect_checksum_type("a".repeat(50).as_str());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid checksum length"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_checksum_sha256_valid() {
|
||||
let data = b"hello";
|
||||
let expected = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
||||
assert!(verify_checksum(data, expected, ChecksumType::Sha256).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_checksum_sha256_invalid() {
|
||||
let data = b"hello";
|
||||
let expected = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
assert!(verify_checksum(data, expected, ChecksumType::Sha256).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_checksum_sha512_valid() {
|
||||
let data = b"hello";
|
||||
let expected = "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043";
|
||||
assert!(verify_checksum(data, expected, ChecksumType::Sha512).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_checksum_sha512_invalid() {
|
||||
let data = b"hello";
|
||||
let expected = "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
|
||||
assert!(verify_checksum(data, expected, ChecksumType::Sha512).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,21 @@ use crate::finalize::plugin::PluginError;
|
||||
use std::path::PathBuf;
|
||||
use tokio::task;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// Compression type for tar archives.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum CompressionType {
|
||||
/// Gzip compressed (.tar.gz, .tgz)
|
||||
Gzip,
|
||||
/// Zstd compressed (.tar.zst, .tar.zstd, .tzst)
|
||||
Zstd,
|
||||
/// Uncompressed tar (.tar)
|
||||
None,
|
||||
}
|
||||
|
||||
impl CompressionType {
|
||||
/// Detects compression type from file path extension.
|
||||
///
|
||||
/// Supports: .tar.gz, .tgz (Gzip), .tar.zst, .tar.zstd, .tzst (Zstd), .tar (None).
|
||||
pub fn from_path(path: &std::path::Path) -> Result<Self, String> {
|
||||
let name = path
|
||||
.file_name()
|
||||
@@ -36,6 +43,10 @@ impl CompressionType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts a tar archive to the destination directory.
|
||||
///
|
||||
/// Removes existing destination directory if present, creates a fresh directory,
|
||||
/// then unpacks the archive using the appropriate decompression based on `compression`.
|
||||
pub async fn extract_tar(
|
||||
archive: &PathBuf,
|
||||
destdir: &PathBuf,
|
||||
@@ -89,3 +100,70 @@ pub async fn extract_tar(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tar_gz() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tar.gz")).unwrap(),
|
||||
CompressionType::Gzip
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tgz() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tgz")).unwrap(),
|
||||
CompressionType::Gzip
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tar_zst() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tar.zst")).unwrap(),
|
||||
CompressionType::Zstd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tar_zstd() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tar.zstd")).unwrap(),
|
||||
CompressionType::Zstd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tzst() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tzst")).unwrap(),
|
||||
CompressionType::Zstd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tar() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tar")).unwrap(),
|
||||
CompressionType::None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_unsupported() {
|
||||
assert!(CompressionType::from_path(Path::new("archive.zip")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_case_insensitive() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.TAR.GZ")).unwrap(),
|
||||
CompressionType::Gzip
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,21 @@ use crate::finalize::plugin::PluginError;
|
||||
use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
/// Version specification for a git repository.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum GitVersion {
|
||||
/// No version specified (invalid for fetch operations)
|
||||
None,
|
||||
/// Single version string (tag or commit hash)
|
||||
Single(String),
|
||||
/// Mixed: try tag first, fallback to commit hash
|
||||
Mixed(String, String),
|
||||
}
|
||||
|
||||
/// Parses a git URL with optional version specification.
|
||||
///
|
||||
/// Format: `url` (no version), `url@tag` (single version), `url@tag:commit` (mixed version).
|
||||
/// Returns (base_url, GitVersion).
|
||||
pub fn parse_git_url(url: &str) -> (String, GitVersion) {
|
||||
if let Some((base, version)) = url.split_once('@') {
|
||||
if version.contains(":") {
|
||||
@@ -24,6 +32,10 @@ pub fn parse_git_url(url: &str) -> (String, GitVersion) {
|
||||
(url.to_string(), GitVersion::None)
|
||||
}
|
||||
|
||||
/// Clones a git repository and checks out the specified version.
|
||||
///
|
||||
/// Removes existing destination directory, performs shallow or full clone,
|
||||
/// then checks out the given version (tag or commit).
|
||||
pub async fn git_clone_and_checkout(
|
||||
baseurl: &str,
|
||||
version: &str,
|
||||
@@ -75,6 +87,10 @@ pub async fn git_clone_and_checkout(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetches a plugin from a git repository.
|
||||
///
|
||||
/// Parses the URL for version info, then clones and checks out the appropriate ref.
|
||||
/// For Mixed version, tries the tag first and falls back to the commit hash.
|
||||
pub async fn fetch_git_plugin(
|
||||
path: &str,
|
||||
name: &str,
|
||||
@@ -121,3 +137,49 @@ pub async fn fetch_git_plugin(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_url_no_version() {
|
||||
let (url, version) = parse_git_url("https://github.com/user/repo");
|
||||
assert_eq!(url, "https://github.com/user/repo");
|
||||
assert_eq!(version, GitVersion::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_url_single_tag() {
|
||||
let (url, version) = parse_git_url("https://github.com/user/repo@v1.0.0");
|
||||
assert_eq!(url, "https://github.com/user/repo");
|
||||
assert_eq!(version, GitVersion::Single("v1.0.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_url_mixed_version() {
|
||||
let (url, version) = parse_git_url("https://github.com/user/repo@main:abc123");
|
||||
assert_eq!(url, "https://github.com/user/repo");
|
||||
assert_eq!(
|
||||
version,
|
||||
GitVersion::Mixed("main".to_string(), "abc123".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_url_complex_url() {
|
||||
let (url, version) = parse_git_url("git@github.com:user/repo.git@tag:commit");
|
||||
assert_eq!(url, "git@github.com:user/repo.git");
|
||||
assert_eq!(
|
||||
version,
|
||||
GitVersion::Mixed("tag".to_string(), "commit".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_url_single_with_at_in_path() {
|
||||
let (url, version) = parse_git_url("https://github.com/user/repo@my-repo@v1.0");
|
||||
assert_eq!(url, "https://github.com/user/repo@my-repo");
|
||||
assert_eq!(version, GitVersion::Single("v1.0".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,84 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Supported checksum types for verifying downloaded artifacts.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ChecksumType {
|
||||
/// SHA-256 hash (64 hex characters)
|
||||
Sha256,
|
||||
/// SHA-512 hash (128 hex characters)
|
||||
Sha512,
|
||||
}
|
||||
|
||||
/// Arguments for fetching a plugin artifact.
|
||||
///
|
||||
/// Contains optional checksum verification and shallow clone settings.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
pub struct FetchArgument {
|
||||
/// SHA256 or SHA512 hex string for integrity verification.
|
||||
#[serde(default)]
|
||||
pub checksum: Option<String>,
|
||||
/// Perform a shallow clone (single revision only).
|
||||
#[serde(default)]
|
||||
pub shallow: Option<bool>,
|
||||
}
|
||||
|
||||
impl FetchArgument {
|
||||
/// Returns the checksum if present and non-empty.
|
||||
pub fn get_checksum(&self) -> Option<&str> {
|
||||
self.checksum.as_deref().filter(|s| !s.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_checksum_some_non_empty() {
|
||||
let arg = FetchArgument {
|
||||
checksum: Some("abc123".to_string()),
|
||||
shallow: None,
|
||||
};
|
||||
assert_eq!(arg.get_checksum(), Some("abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_checksum_empty_string() {
|
||||
let arg = FetchArgument {
|
||||
checksum: Some("".to_string()),
|
||||
shallow: None,
|
||||
};
|
||||
assert_eq!(arg.get_checksum(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_checksum_none() {
|
||||
let arg = FetchArgument {
|
||||
checksum: None,
|
||||
shallow: None,
|
||||
};
|
||||
assert_eq!(arg.get_checksum(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip() {
|
||||
let arg = FetchArgument {
|
||||
checksum: Some("deadbeef".to_string()),
|
||||
shallow: Some(true),
|
||||
};
|
||||
let json = serde_json::to_string(&arg).unwrap();
|
||||
let parsed: FetchArgument = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.checksum, arg.checksum);
|
||||
assert_eq!(parsed.shallow, arg.shallow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_empty() {
|
||||
let arg = FetchArgument::default();
|
||||
let json = serde_json::to_string(&arg).unwrap();
|
||||
let parsed: FetchArgument = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.checksum, arg.checksum);
|
||||
assert_eq!(parsed.shallow, arg.shallow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use crate::types::time::deserialize_duration_ms;
|
||||
use workshop_engine::deserialize_duration_ms;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Email address representation supporting plain string or structured object.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum EmailAddress {
|
||||
/// Plain email address string.
|
||||
String(String),
|
||||
/// Structured email with display name.
|
||||
Object { name: String, email: String },
|
||||
}
|
||||
|
||||
impl EmailAddress {
|
||||
/// Returns the formatted address string (e.g., "user@example.com" or "Name <user@example.com>").
|
||||
pub fn to_string(&self) -> String {
|
||||
match self {
|
||||
EmailAddress::String(email) => email.clone(),
|
||||
@@ -23,6 +27,7 @@ impl EmailAddress {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the raw email address without formatting.
|
||||
pub fn email(&self) -> &str {
|
||||
match self {
|
||||
EmailAddress::String(email) => email.as_str(),
|
||||
@@ -31,14 +36,18 @@ impl EmailAddress {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recipients container supporting single or multiple addresses.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum Recipients {
|
||||
/// Single recipient.
|
||||
Single(String),
|
||||
/// Multiple recipients.
|
||||
Multiple(Vec<String>),
|
||||
}
|
||||
|
||||
impl Recipients {
|
||||
/// Converts recipients to a flat vector of email strings.
|
||||
pub fn to_vec(&self) -> Vec<String> {
|
||||
match self {
|
||||
Recipients::Single(s) => vec![s.clone()],
|
||||
@@ -47,36 +56,52 @@ impl Recipients {
|
||||
}
|
||||
}
|
||||
|
||||
/// SMTP authentication mechanism.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthType {
|
||||
/// No authentication.
|
||||
None,
|
||||
/// Password-based authentication.
|
||||
Password,
|
||||
}
|
||||
|
||||
/// SMTP authentication configuration.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct AuthConfig {
|
||||
/// Authentication mechanism type.
|
||||
#[serde(rename = "type")]
|
||||
pub auth_type: AuthType,
|
||||
/// Username for authentication.
|
||||
pub username: String,
|
||||
/// Password for authentication.
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// SMTP connection encryption mode.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Encryption {
|
||||
/// Implicit TLS/SSL on connection.
|
||||
Tls,
|
||||
/// STARTTLS upgrade after initial plaintext connection.
|
||||
Starttls,
|
||||
/// No encryption.
|
||||
None,
|
||||
}
|
||||
|
||||
/// SMTP server connection configuration.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct SmtpConfig {
|
||||
/// SMTP server hostname.
|
||||
pub host: String,
|
||||
/// SMTP server port.
|
||||
pub port: u16,
|
||||
/// Authentication configuration.
|
||||
pub auth: AuthConfig,
|
||||
/// Encryption mode.
|
||||
pub encryption: Encryption,
|
||||
|
||||
/// Connection timeout in milliseconds.
|
||||
#[serde(
|
||||
default = "default_connect_timeout_ms",
|
||||
deserialize_with = "deserialize_duration_ms"
|
||||
@@ -88,10 +113,13 @@ fn default_connect_timeout_ms() -> u64 {
|
||||
30_000
|
||||
}
|
||||
|
||||
/// Mail schema type determining required fields.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Schema {
|
||||
/// Default schema with minimal required fields.
|
||||
Default,
|
||||
/// Custom schema requiring explicit subject and body.
|
||||
Custom,
|
||||
}
|
||||
|
||||
@@ -99,32 +127,36 @@ fn default_schema() -> Schema {
|
||||
Schema::Default
|
||||
}
|
||||
|
||||
/// Complete mail notification configuration.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct MailConfig {
|
||||
/// Primary recipients.
|
||||
pub to: Recipients,
|
||||
|
||||
/// Carbon copy recipients.
|
||||
#[serde(default)]
|
||||
pub cc: Option<Recipients>,
|
||||
|
||||
/// Blind carbon copy recipients.
|
||||
#[serde(default)]
|
||||
pub bcc: Option<Recipients>,
|
||||
|
||||
/// Sender address.
|
||||
pub from: EmailAddress,
|
||||
|
||||
/// Reply-to address.
|
||||
#[serde(default)]
|
||||
pub reply_to: Option<String>,
|
||||
|
||||
/// Schema type determining validation rules.
|
||||
#[serde(default = "default_schema")]
|
||||
pub schema: Schema,
|
||||
|
||||
/// Email subject (required for custom schema).
|
||||
pub subject: Option<String>,
|
||||
|
||||
/// Email body content (required for custom schema).
|
||||
pub body: Option<String>,
|
||||
|
||||
/// SMTP server configuration.
|
||||
pub smtp: SmtpConfig,
|
||||
}
|
||||
|
||||
impl MailConfig {
|
||||
/// Validates the mail configuration according to schema rules.
|
||||
/// Returns Ok(()) if valid, or Err(vec) with error messages if invalid.
|
||||
pub fn validate(&self) -> Result<(), Vec<String>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
@@ -149,12 +181,15 @@ impl MailConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in email notification templates.
|
||||
pub struct DefaultTemplates;
|
||||
|
||||
impl DefaultTemplates {
|
||||
/// Default subject template with build status.
|
||||
pub const SUBJECT: &'static str =
|
||||
"[{{ build.status | upper }}] {{ pipeline.name }} #{{ build.id }}";
|
||||
|
||||
/// Default HTML email body template.
|
||||
pub const BODY_HTML: &'static str = r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif;">
|
||||
@@ -177,6 +212,7 @@ impl DefaultTemplates {
|
||||
</body>
|
||||
</html>"#;
|
||||
|
||||
/// Default plain text email body template.
|
||||
pub const BODY_TEXT: &'static str = r#"Build {{ build.status | title }}
|
||||
==============================
|
||||
Pipeline: {{ pipeline.name }}
|
||||
@@ -189,3 +225,266 @@ Message: {{ commit.message }}
|
||||
|
||||
View details: {{ build.url }}"#;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_email_address_string_to_string() {
|
||||
let addr = EmailAddress::String("test@example.com".to_string());
|
||||
assert_eq!(addr.to_string(), "test@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_address_object_with_name_to_string() {
|
||||
let addr = EmailAddress::Object {
|
||||
name: "Test User".to_string(),
|
||||
email: "test@example.com".to_string(),
|
||||
};
|
||||
assert_eq!(addr.to_string(), "Test User <test@example.com>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_address_object_without_name_to_string() {
|
||||
let addr = EmailAddress::Object {
|
||||
name: "".to_string(),
|
||||
email: "test@example.com".to_string(),
|
||||
};
|
||||
assert_eq!(addr.to_string(), "test@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_address_string_email() {
|
||||
let addr = EmailAddress::String("test@example.com".to_string());
|
||||
assert_eq!(addr.email(), "test@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_address_object_email() {
|
||||
let addr = EmailAddress::Object {
|
||||
name: "Test User".to_string(),
|
||||
email: "test@example.com".to_string(),
|
||||
};
|
||||
assert_eq!(addr.email(), "test@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recipients_single_to_vec() {
|
||||
let recipients = Recipients::Single("test@example.com".to_string());
|
||||
let vec = recipients.to_vec();
|
||||
assert_eq!(vec, vec!["test@example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recipients_multiple_to_vec() {
|
||||
let recipients = Recipients::Multiple(vec![
|
||||
"a@example.com".to_string(),
|
||||
"b@example.com".to_string(),
|
||||
]);
|
||||
let vec = recipients.to_vec();
|
||||
assert_eq!(vec, vec!["a@example.com", "b@example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_config_validate_valid_default_schema() {
|
||||
let config = MailConfig {
|
||||
to: Recipients::Single("to@example.com".to_string()),
|
||||
cc: None,
|
||||
bcc: None,
|
||||
from: EmailAddress::String("from@example.com".to_string()),
|
||||
reply_to: None,
|
||||
schema: Schema::Default,
|
||||
subject: None,
|
||||
body: None,
|
||||
smtp: SmtpConfig {
|
||||
host: "smtp.example.com".to_string(),
|
||||
port: 587,
|
||||
auth: AuthConfig {
|
||||
auth_type: AuthType::None,
|
||||
username: "".to_string(),
|
||||
password: "".to_string(),
|
||||
},
|
||||
encryption: Encryption::Tls,
|
||||
connect_timeout_ms: 30_000,
|
||||
},
|
||||
};
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_config_validate_valid_custom_schema() {
|
||||
let config = MailConfig {
|
||||
to: Recipients::Single("to@example.com".to_string()),
|
||||
cc: None,
|
||||
bcc: None,
|
||||
from: EmailAddress::String("from@example.com".to_string()),
|
||||
reply_to: None,
|
||||
schema: Schema::Custom,
|
||||
subject: Some("Test Subject".to_string()),
|
||||
body: Some("Test Body".to_string()),
|
||||
smtp: SmtpConfig {
|
||||
host: "smtp.example.com".to_string(),
|
||||
port: 587,
|
||||
auth: AuthConfig {
|
||||
auth_type: AuthType::None,
|
||||
username: "".to_string(),
|
||||
password: "".to_string(),
|
||||
},
|
||||
encryption: Encryption::Tls,
|
||||
connect_timeout_ms: 30_000,
|
||||
},
|
||||
};
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_config_validate_custom_schema_missing_subject() {
|
||||
let config = MailConfig {
|
||||
to: Recipients::Single("to@example.com".to_string()),
|
||||
cc: None,
|
||||
bcc: None,
|
||||
from: EmailAddress::String("from@example.com".to_string()),
|
||||
reply_to: None,
|
||||
schema: Schema::Custom,
|
||||
subject: None,
|
||||
body: Some("Test Body".to_string()),
|
||||
smtp: SmtpConfig {
|
||||
host: "smtp.example.com".to_string(),
|
||||
port: 587,
|
||||
auth: AuthConfig {
|
||||
auth_type: AuthType::None,
|
||||
username: "".to_string(),
|
||||
password: "".to_string(),
|
||||
},
|
||||
encryption: Encryption::Tls,
|
||||
connect_timeout_ms: 30_000,
|
||||
},
|
||||
};
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().iter().any(|e| e.contains("subject")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_config_validate_custom_schema_missing_body() {
|
||||
let config = MailConfig {
|
||||
to: Recipients::Single("to@example.com".to_string()),
|
||||
cc: None,
|
||||
bcc: None,
|
||||
from: EmailAddress::String("from@example.com".to_string()),
|
||||
reply_to: None,
|
||||
schema: Schema::Custom,
|
||||
subject: Some("Test Subject".to_string()),
|
||||
body: None,
|
||||
smtp: SmtpConfig {
|
||||
host: "smtp.example.com".to_string(),
|
||||
port: 587,
|
||||
auth: AuthConfig {
|
||||
auth_type: AuthType::None,
|
||||
username: "".to_string(),
|
||||
password: "".to_string(),
|
||||
},
|
||||
encryption: Encryption::Tls,
|
||||
connect_timeout_ms: 30_000,
|
||||
},
|
||||
};
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().iter().any(|e| e.contains("body")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_config_validate_empty_recipients() {
|
||||
let config = MailConfig {
|
||||
to: Recipients::Multiple(vec![]),
|
||||
cc: None,
|
||||
bcc: None,
|
||||
from: EmailAddress::String("from@example.com".to_string()),
|
||||
reply_to: None,
|
||||
schema: Schema::Default,
|
||||
subject: None,
|
||||
body: None,
|
||||
smtp: SmtpConfig {
|
||||
host: "smtp.example.com".to_string(),
|
||||
port: 587,
|
||||
auth: AuthConfig {
|
||||
auth_type: AuthType::None,
|
||||
username: "".to_string(),
|
||||
password: "".to_string(),
|
||||
},
|
||||
encryption: Encryption::Tls,
|
||||
connect_timeout_ms: 30_000,
|
||||
},
|
||||
};
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().iter().any(|e| e.contains("recipient")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_address_serde_string() {
|
||||
let json = r#""test@example.com""#;
|
||||
let addr: EmailAddress = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(addr, EmailAddress::String(s) if s == "test@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_address_serde_object() {
|
||||
let json = r#"{"name":"Test User","email":"test@example.com"}"#;
|
||||
let addr: EmailAddress = serde_json::from_str(json).unwrap();
|
||||
assert!(
|
||||
matches!(addr, EmailAddress::Object { name, email } if name == "Test User" && email == "test@example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recipients_serde_single() {
|
||||
let json = r#""single@example.com""#;
|
||||
let recipients: Recipients = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(recipients, Recipients::Single(s) if s == "single@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recipients_serde_multiple() {
|
||||
let json = r#"["a@example.com","b@example.com"]"#;
|
||||
let recipients: Recipients = serde_json::from_str(json).unwrap();
|
||||
assert!(
|
||||
matches!(recipients, Recipients::Multiple(v) if v == vec!["a@example.com".to_string(), "b@example.com".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_type_serde() {
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
struct Test {
|
||||
#[serde(rename = "type")]
|
||||
auth_type: AuthType,
|
||||
}
|
||||
let json = r#"{"type":"password"}"#;
|
||||
let t: Test = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(t.auth_type, AuthType::Password));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encryption_serde() {
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
struct Test {
|
||||
encryption: Encryption,
|
||||
}
|
||||
let json = r#"{"encryption":"tls"}"#;
|
||||
let t: Test = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(t.encryption, Encryption::Tls));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_serde() {
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
struct Test {
|
||||
schema: Schema,
|
||||
}
|
||||
let json = r#"{"schema":"custom"}"#;
|
||||
let t: Test = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(t.schema, Schema::Custom));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ use crate::ExecutionContext;
|
||||
use minijinja::Environment;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Builds template context data from execution context.
|
||||
/// Populates pipeline, build, and commit information for template rendering.
|
||||
pub fn prepare_template_data(ctx: &ExecutionContext) -> HashMap<String, serde_json::Value> {
|
||||
let mut data = HashMap::new();
|
||||
|
||||
@@ -38,6 +40,8 @@ pub fn prepare_template_data(ctx: &ExecutionContext) -> HashMap<String, serde_js
|
||||
data
|
||||
}
|
||||
|
||||
/// Renders a minijinja template string with the provided data map.
|
||||
/// Returns the rendered string on success, or a String error message on failure.
|
||||
pub fn render_template(
|
||||
template: &str,
|
||||
data: &HashMap<String, serde_json::Value>,
|
||||
@@ -54,3 +58,44 @@ pub fn render_template(
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_render_template_simple() {
|
||||
let template = "Hello {{ name }}!";
|
||||
let mut data = HashMap::new();
|
||||
data.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String("World".to_string()),
|
||||
);
|
||||
let result = render_template(template, &data).unwrap();
|
||||
assert_eq!(result, "Hello World!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_template_with_build_status() {
|
||||
let template = "Build {{ build.status }} for pipeline {{ pipeline.name }}";
|
||||
let mut data = HashMap::new();
|
||||
data.insert(
|
||||
"build".to_string(),
|
||||
serde_json::json!({ "status": "success" }),
|
||||
);
|
||||
data.insert(
|
||||
"pipeline".to_string(),
|
||||
serde_json::json!({ "name": "test-pipeline" }),
|
||||
);
|
||||
let result = render_template(template, &data).unwrap();
|
||||
assert_eq!(result, "Build success for pipeline test-pipeline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_template_invalid_syntax() {
|
||||
let template = "{{ Unterminated";
|
||||
let data = HashMap::new();
|
||||
let result = render_template(template, &data);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
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 not captured by struct.
|
||||
#[serde(flatten)]
|
||||
pub value: serde_yaml::Value,
|
||||
|
||||
/// Filesystem path to plugin definition.
|
||||
#[serde(skip)]
|
||||
pub fspath: PathBuf,
|
||||
}
|
||||
@@ -26,12 +35,14 @@ impl Default for PluginMetadata {
|
||||
entrypoint: String::new(),
|
||||
plugin_type: PluginType::Unknown,
|
||||
value: serde_yaml::Value::Null,
|
||||
renderer: None,
|
||||
fspath: PathBuf::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, PartialEq, Eq, Clone)]
|
||||
/// Plugin execution model.
|
||||
#[derive(Deserialize, Default, PartialEq, Eq, Clone, Debug)]
|
||||
pub enum PluginType {
|
||||
#[default]
|
||||
Unknown,
|
||||
@@ -39,3 +50,76 @@ pub enum PluginType {
|
||||
Rhai,
|
||||
Dylib,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_plugin_metadata_default() {
|
||||
let meta = PluginMetadata::default();
|
||||
assert_eq!(meta.name, "");
|
||||
assert_eq!(meta.version, "");
|
||||
assert_eq!(meta.entrypoint, "");
|
||||
assert_eq!(meta.plugin_type, PluginType::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_type_default() {
|
||||
let pt = PluginType::default();
|
||||
assert_eq!(pt, PluginType::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_type_equality() {
|
||||
assert_eq!(PluginType::Shell, PluginType::Shell);
|
||||
assert_eq!(PluginType::Dylib, PluginType::Dylib);
|
||||
assert_ne!(PluginType::Shell, PluginType::Dylib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_metadata_clone() {
|
||||
let meta = PluginMetadata {
|
||||
name: "test".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
entrypoint: "/bin/test".to_string(),
|
||||
plugin_type: PluginType::Shell,
|
||||
value: serde_yaml::Value::Null,
|
||||
fspath: PathBuf::from("/path/to/plugin"),
|
||||
renderer: None,
|
||||
};
|
||||
let cloned = meta.clone();
|
||||
assert_eq!(cloned.name, meta.name);
|
||||
assert_eq!(cloned.version, meta.version);
|
||||
assert_eq!(cloned.plugin_type, meta.plugin_type);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_type_serde_unknown() {
|
||||
let yaml = "Unknown";
|
||||
let pt: PluginType = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(pt, PluginType::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_type_serde_shell() {
|
||||
let yaml = "Shell";
|
||||
let pt: PluginType = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(pt, PluginType::Shell);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_metadata_serde() {
|
||||
let yaml = r#"
|
||||
name: test-plugin
|
||||
version: "2.0"
|
||||
entrypoint: ./bin/test
|
||||
plugin_type: Shell
|
||||
"#;
|
||||
let meta: PluginMetadata = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(meta.name, "test-plugin");
|
||||
assert_eq!(meta.version, "2.0");
|
||||
assert_eq!(meta.entrypoint, "./bin/test");
|
||||
assert_eq!(meta.plugin_type, PluginType::Shell);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::Engine;
|
||||
use crate::constant::FINALIZE_SHELL_ENVPREFIX;
|
||||
use crate::finalize::constant::FINALIZE_SHELL_ENVPREFIX;
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use crate::ExecutionContext;
|
||||
use crate::engine::types::DeltaExecutionContext;
|
||||
use crate::engine::{Engine, EventSender, ExecutionError};
|
||||
use workshop_engine::{DeltaExecutionContext, Engine, EventSender, ExecutionError, CustomCommand};
|
||||
use crate::finalize::plugin::{PluginMap, call_named_plugin};
|
||||
use crate::types::command::CustomCommand;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 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,
|
||||
@@ -21,7 +24,6 @@ pub async fn hook(
|
||||
let engine = Engine::new();
|
||||
for (index, hook_cmd) in hook.iter().enumerate() {
|
||||
if hook_cmd.command.starts_with("plugin:") {
|
||||
// Treat as a 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() {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod compression;
|
||||
@@ -0,0 +1,94 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Compression method for build artifacts.
|
||||
///
|
||||
/// Defaults to `Zstd` when used in configuration.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CompressionMethod {
|
||||
/// Zstandard compression (default).
|
||||
#[default]
|
||||
Zstd,
|
||||
/// Gzip compression.
|
||||
Gzip,
|
||||
/// No compression (store as-is).
|
||||
None,
|
||||
/// Directory mode (no compression, store as directory).
|
||||
Dir,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_is_zstd() {
|
||||
let method = CompressionMethod::default();
|
||||
match method {
|
||||
CompressionMethod::Zstd => {}
|
||||
other => panic!("expected Zstd, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_zstd() {
|
||||
let method = CompressionMethod::Zstd;
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
assert_eq!(json, "\"zstd\"");
|
||||
let parsed: CompressionMethod = serde_json::from_str(&json).unwrap();
|
||||
match parsed {
|
||||
CompressionMethod::Zstd => {}
|
||||
other => panic!("expected Zstd, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_gzip() {
|
||||
let method = CompressionMethod::Gzip;
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
assert_eq!(json, "\"gzip\"");
|
||||
let parsed: CompressionMethod = serde_json::from_str(&json).unwrap();
|
||||
match parsed {
|
||||
CompressionMethod::Gzip => {}
|
||||
other => panic!("expected Gzip, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_none() {
|
||||
let method = CompressionMethod::None;
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
assert_eq!(json, "\"none\"");
|
||||
let parsed: CompressionMethod = serde_json::from_str(&json).unwrap();
|
||||
match parsed {
|
||||
CompressionMethod::None => {}
|
||||
other => panic!("expected None, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_dir() {
|
||||
let method = CompressionMethod::Dir;
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
assert_eq!(json, "\"dir\"");
|
||||
let parsed: CompressionMethod = serde_json::from_str(&json).unwrap();
|
||||
match parsed {
|
||||
CompressionMethod::Dir => {}
|
||||
other => panic!("expected Dir, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_deserialize_lowercase() {
|
||||
let parsed: CompressionMethod = serde_json::from_str("\"zstd\"").unwrap();
|
||||
match parsed {
|
||||
CompressionMethod::Zstd => {}
|
||||
other => panic!("expected Zstd, got {:?}", other),
|
||||
}
|
||||
let parsed: CompressionMethod = serde_json::from_str("\"gzip\"").unwrap();
|
||||
match parsed {
|
||||
CompressionMethod::Gzip => {}
|
||||
other => panic!("expected Gzip, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,7 @@
|
||||
// Selective exports: only modules used by external callers or tests
|
||||
|
||||
pub mod bake;
|
||||
pub mod constant;
|
||||
pub mod daemon;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod finalize;
|
||||
pub mod monitor;
|
||||
pub mod notify;
|
||||
@@ -16,8 +13,8 @@ pub mod types;
|
||||
// External crates re-exports
|
||||
pub use config;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use engine::{Engine, EventSender, ExecutionContext, ExecutionError, ExecutionResult};
|
||||
// Re-export commonly used types from workshop-engine
|
||||
pub use workshop_engine::{Engine, EventSender, ExecutionContext, ExecutionError, ExecutionResult};
|
||||
|
||||
// CLI types used by main (keep here since they're used in daemon)
|
||||
pub use clap::{Parser, Subcommand};
|
||||
|
||||
@@ -4,11 +4,10 @@ use workshop_baker::{
|
||||
bake::bake,
|
||||
cli::{BareCommands, Cli, Commands, Parser},
|
||||
daemon,
|
||||
engine::{EventSender, ExecutionContext},
|
||||
error::HasExitCode,
|
||||
finalize::finalize,
|
||||
prebake::prebake,
|
||||
};
|
||||
use workshop_engine::{error::HasExitCode, EventSender, ExecutionContext};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -69,11 +68,11 @@ async fn main() {
|
||||
if let Err(ref e) = result {
|
||||
let code = match command {
|
||||
BareCommands::Prebake { .. } => e
|
||||
.downcast_ref::<workshop_baker::error::PrebakeError>()
|
||||
.downcast_ref::<workshop_baker::prebake::error::PrebakeError>()
|
||||
.map(|pe| pe.exit_code())
|
||||
.unwrap_or(1),
|
||||
BareCommands::Bake { .. } => e
|
||||
.downcast_ref::<workshop_baker::error::BakeError>()
|
||||
.downcast_ref::<workshop_baker::bake::error::BakeError>()
|
||||
.map(|be| be.exit_code())
|
||||
.unwrap_or(1),
|
||||
BareCommands::Finalize { .. } => e
|
||||
|
||||
+239
-12
@@ -1,24 +1,37 @@
|
||||
use crate::constant::FINALIZE_STANDALONE_PLUGIN_PATH;
|
||||
use crate::finalize::constant::FINALIZE_STANDALONE_PLUGIN_PATH;
|
||||
use crate::finalize::FinalizeConfig;
|
||||
use crate::finalize::NotificationConfig;
|
||||
use crate::finalize::parse;
|
||||
use crate::finalize::plugin;
|
||||
use crate::finalize::plugin::fetch;
|
||||
use crate::notify::types::BundledNotificationEvent;
|
||||
use crate::notify::types::NOTIFY_TEMPORARY_DELAY_FACTOR;
|
||||
use crate::notify::types::NOTIFY_TEMPORARY_DELAY_TIME;
|
||||
use crate::notify::types::NOTIFY_TEMPORARY_MAX_TIME;
|
||||
use crate::notify::types::NotificationRulesMap;
|
||||
use crate::notify::types::Method;
|
||||
use crate::notify::types::NOTIFICATION_TEMPORARY_DELAY_FACTOR;
|
||||
use crate::notify::types::NOTIFICATION_TEMPORARY_DELAY_TIME;
|
||||
use crate::notify::types::NOTIFICATION_TEMPORARY_MAX_TIME;
|
||||
use crate::notify::types::NotificationEvent;
|
||||
use crate::notify::types::NotificationEventReceiver;
|
||||
use crate::notify::types::NotificationEventSender;
|
||||
use crate::notify::types::NotificationPluginMap;
|
||||
use crate::notify::types::NotificationRenderer;
|
||||
use crate::notify::types::NotificationRule;
|
||||
use crate::notify::types::NotificationRulesMap;
|
||||
use crate::{EventSender, ExecutionContext};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use crate::notify::types::ParsedTrigger;
|
||||
use crate::notify::types::Matchable;
|
||||
use globset::{Glob, GlobSetBuilder};
|
||||
use crate::finalize::config::TriggerItemRaw;
|
||||
use crate::finalize::NotificationTemplate;
|
||||
use crate::notify::types::NOTIFICATION_DEFAULT_FALLIBLE;
|
||||
use crate::notify::types::NOTIFICATION_DEFAULT_MAX_LIVES;
|
||||
use crate::notify::types::NOTIFICATION_DEFAULT_TIMEOUT_MS;
|
||||
use crate::notify::types::NOTIFICATION_DEFAULT_RETRY_BACKOFF;
|
||||
|
||||
mod error;
|
||||
mod types;
|
||||
pub mod types;
|
||||
use error::NotifyError;
|
||||
|
||||
fn validate(finalize: &FinalizeConfig) -> anyhow::Result<()> {
|
||||
@@ -69,6 +82,8 @@ pub async fn notify(
|
||||
}
|
||||
let notify_plugins = plugin::register(finalize.plugin, Some(&plugin_list))?;
|
||||
|
||||
let notification_rules = parse_rules(&finalize.notification);
|
||||
|
||||
// loop {
|
||||
// tokio::select! {
|
||||
// Some(events) = notifyevent_rx.recv() => {
|
||||
@@ -116,12 +131,224 @@ pub async fn notify(
|
||||
fn notification_handler(
|
||||
plugins: &NotificationPluginMap,
|
||||
config: &NotificationRulesMap,
|
||||
event: &BundledNotificationEvent,
|
||||
event: &NotificationEvent,
|
||||
) -> Result<(), NotifyError> {
|
||||
for (priority, ruleset) in config.iter() {
|
||||
for (method, rules) in ruleset.iter() {
|
||||
if !event.target.contains(method) {
|
||||
log::trace!("target does not contain method name: {}", method);
|
||||
continue;
|
||||
}
|
||||
for (rule_id, rule) in rules.iter().enumerate() {
|
||||
if !rule.enabled {
|
||||
log::trace!("rule is not enabled: {}", rule_id);
|
||||
continue;
|
||||
}
|
||||
if rule.accept(&event.outcome, &event.stage, &event.substage) {
|
||||
let plugin = plugins
|
||||
.get(method)
|
||||
.ok_or(NotifyError::UnknownPlugin(method.clone()))?;
|
||||
let renderer = plugin.metadata.renderer.clone().unwrap_or_default();
|
||||
match renderer {
|
||||
NotificationRenderer::Server => {
|
||||
// ...
|
||||
todo!();
|
||||
}
|
||||
NotificationRenderer::Plugin => {
|
||||
unimplemented!();
|
||||
return Err(NotifyError::UnsupportedRenderer(method.clone(), renderer));
|
||||
}
|
||||
NotificationRenderer::Interactive => {
|
||||
unimplemented!();
|
||||
return Err(NotifyError::UnsupportedRenderer(method.clone(), renderer));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
todo!();
|
||||
}
|
||||
|
||||
fn parse_trigger_item_raw(item: &TriggerItemRaw) -> Vec<ParsedTrigger> {
|
||||
match item {
|
||||
TriggerItemRaw::Simple(s) => {
|
||||
match s.as_str() {
|
||||
"on_success" => vec![ParsedTrigger::OnSuccess],
|
||||
"on_failure" => vec![ParsedTrigger::OnFailure],
|
||||
s if s.starts_with("on_finish_of:") => {
|
||||
let pattern = s.trim_start_matches("on_finish_of:").trim();
|
||||
let mut builder = GlobSetBuilder::new();
|
||||
builder.add(Glob::new(pattern).expect("Invalid glob pattern"));
|
||||
let glob_set = builder.build().expect("Failed to compile glob set");
|
||||
vec![ParsedTrigger::OnFinishOf(Matchable::Glob(glob_set))]
|
||||
}
|
||||
_ => {
|
||||
log::warn!("Unknown trigger string: {}", s);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
TriggerItemRaw::Map(map) => {
|
||||
let mut triggers = Vec::new();
|
||||
for (key, patterns) in map {
|
||||
match key.as_str() {
|
||||
"on_finish_of" => {
|
||||
for pattern in patterns {
|
||||
let mut builder = GlobSetBuilder::new();
|
||||
builder.add(Glob::new(pattern).expect("Invalid glob pattern"));
|
||||
let glob_set = builder.build().expect("Failed to compile glob set");
|
||||
triggers.push(ParsedTrigger::OnFinishOf(Matchable::Glob(glob_set)));
|
||||
}
|
||||
}
|
||||
"on_success" => triggers.push(ParsedTrigger::OnSuccess),
|
||||
"on_failure" => triggers.push(ParsedTrigger::OnFailure),
|
||||
_ => {
|
||||
log::warn!("Unknown trigger key: {}", key);
|
||||
}
|
||||
}
|
||||
}
|
||||
triggers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_templates(
|
||||
method_template: &NotificationTemplate,
|
||||
policy_template: &Option<serde_yaml::Value>,
|
||||
) -> NotificationTemplate {
|
||||
let mut merged = method_template.clone();
|
||||
if let Some(policy_tmpl) = policy_template {
|
||||
if let Ok(policy_template) =
|
||||
serde_yaml::from_value::<NotificationTemplate>(policy_tmpl.clone())
|
||||
{
|
||||
if policy_template.schema.is_some() {
|
||||
merged.schema = policy_template.schema;
|
||||
}
|
||||
if policy_template.on_success.is_some() {
|
||||
merged.on_success = policy_template.on_success;
|
||||
}
|
||||
if policy_template.on_failure.is_some() {
|
||||
merged.on_failure = policy_template.on_failure;
|
||||
}
|
||||
if policy_template.on_finish_of.is_some() {
|
||||
merged.on_finish_of = policy_template.on_finish_of;
|
||||
}
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
fn extract_overrides(overrides: &serde_yaml::Value) -> (bool, u32, std::time::Duration, f64) {
|
||||
let fallible = overrides
|
||||
.get("fallible")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(NOTIFICATION_DEFAULT_FALLIBLE);
|
||||
let max_lives = overrides
|
||||
.get("max_lives")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(NOTIFICATION_DEFAULT_MAX_LIVES as u64) as u32;
|
||||
let timeout_ms = overrides
|
||||
.get("timeout_ms")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(NOTIFICATION_DEFAULT_TIMEOUT_MS);
|
||||
let retry_backoff = overrides
|
||||
.get("retry_backoff")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(NOTIFICATION_DEFAULT_RETRY_BACKOFF);
|
||||
(
|
||||
fallible,
|
||||
max_lives,
|
||||
std::time::Duration::from_millis(timeout_ms),
|
||||
retry_backoff,
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_rules(config: &NotificationConfig) -> NotificationRulesMap {
|
||||
let mut rules = NotificationRulesMap::default();
|
||||
for (priority, group) in &config.groups {
|
||||
let mut ruleset: HashMap<Method, Vec<NotificationRule>> = HashMap::new();
|
||||
for (method_name, method) in group {
|
||||
let mut method_rules: Vec<NotificationRule> = Vec::new();
|
||||
|
||||
if method.policy.is_empty() {
|
||||
let trigger: Vec<ParsedTrigger> = method
|
||||
.trigger
|
||||
.iter()
|
||||
.flat_map(parse_trigger_item_raw)
|
||||
.collect();
|
||||
let template = method.template.clone();
|
||||
let (fallible, max_lives, timeout, retry_backoff) =
|
||||
extract_overrides(&method.config);
|
||||
method_rules.push(NotificationRule {
|
||||
enabled: true,
|
||||
trigger,
|
||||
template,
|
||||
fallible,
|
||||
max_lives,
|
||||
timeout,
|
||||
config: method.config.clone(),
|
||||
retry_backoff,
|
||||
});
|
||||
} else {
|
||||
for policy in &method.policy {
|
||||
let trigger: Vec<ParsedTrigger> = if policy.trigger.is_empty() {
|
||||
method
|
||||
.trigger
|
||||
.iter()
|
||||
.flat_map(parse_trigger_item_raw)
|
||||
.collect()
|
||||
} else {
|
||||
policy
|
||||
.trigger
|
||||
.iter()
|
||||
.flat_map(parse_trigger_item_raw)
|
||||
.collect()
|
||||
};
|
||||
let template = merge_templates(&method.template, &Some(policy.template.clone()));
|
||||
let method_overrides = extract_overrides(&method.config);
|
||||
let policy_overrides = extract_overrides(&policy.overrides);
|
||||
let fallible = if policy.overrides.get("fallible").is_some() {
|
||||
policy_overrides.0
|
||||
} else {
|
||||
method_overrides.0
|
||||
};
|
||||
let max_lives = if policy.overrides.get("max_lives").is_some() {
|
||||
policy_overrides.1
|
||||
} else {
|
||||
method_overrides.1
|
||||
};
|
||||
let timeout = if policy.overrides.get("timeout_ms").is_some() {
|
||||
policy_overrides.2
|
||||
} else {
|
||||
method_overrides.2
|
||||
};
|
||||
let retry_backoff = if policy.overrides.get("retry_backoff").is_some() {
|
||||
policy_overrides.3
|
||||
} else {
|
||||
method_overrides.3
|
||||
};
|
||||
method_rules.push(NotificationRule {
|
||||
enabled: true,
|
||||
trigger,
|
||||
template,
|
||||
fallible,
|
||||
max_lives,
|
||||
timeout,
|
||||
config: method.config.clone(),
|
||||
retry_backoff,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ruleset.insert(method_name.clone(), method_rules);
|
||||
}
|
||||
rules.insert(*priority, ruleset);
|
||||
}
|
||||
rules
|
||||
}
|
||||
|
||||
// calculate min(1, k/x)*t as notification grouping period
|
||||
// to avoid excessive notification frequency
|
||||
fn wait_time(group_period: u32, group_watermark: u32, count: u32) -> f32 {
|
||||
@@ -141,16 +368,16 @@ fn notify_delay(event: &NotificationEvent) -> std::time::Duration {
|
||||
event.lives,
|
||||
event.max_lives
|
||||
);
|
||||
return std::time::Duration::from_secs_f64(NOTIFY_TEMPORARY_MAX_TIME);
|
||||
return std::time::Duration::from_secs_f64(NOTIFICATION_TEMPORARY_MAX_TIME);
|
||||
}
|
||||
let multiplier = NOTIFY_TEMPORARY_DELAY_FACTOR.powi(exponent);
|
||||
let delay = multiplier * NOTIFY_TEMPORARY_DELAY_TIME;
|
||||
let multiplier = NOTIFICATION_TEMPORARY_DELAY_FACTOR.powi(exponent);
|
||||
let delay = multiplier * NOTIFICATION_TEMPORARY_DELAY_TIME;
|
||||
if !delay.is_finite() || delay < 0.0 {
|
||||
log::warn!(
|
||||
"Unexpected calculated delay: {}, treating as infinite",
|
||||
delay
|
||||
);
|
||||
return std::time::Duration::from_secs_f64(NOTIFY_TEMPORARY_MAX_TIME);
|
||||
return std::time::Duration::from_secs_f64(NOTIFICATION_TEMPORARY_MAX_TIME);
|
||||
}
|
||||
std::time::Duration::from_secs_f64(delay)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::notify::NotificationRenderer;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
@@ -16,4 +17,10 @@ pub enum NotifyError {
|
||||
|
||||
#[error("Notification failed after {0} attempt(s)")]
|
||||
Exhausted(u32),
|
||||
|
||||
#[error("Unknown plugin: {0}")]
|
||||
UnknownPlugin(String),
|
||||
|
||||
#[error("Unsupported renderer for {0}: {1}")]
|
||||
UnsupportedRenderer(String, NotificationRenderer),
|
||||
}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
use crate::finalize::plugin::FinalizePlugin;
|
||||
use crate::types::buildstatus::StageOutcome;
|
||||
use crate::types::buildstatus::StagePhase;
|
||||
use chrono::{DateTime, Utc};
|
||||
use globset::GlobSet;
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
use crate::finalize::plugin::FinalizePlugin;
|
||||
use crate::types::buildstatus::StagePhase;
|
||||
use crate::types::buildstatus::StageResult;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use std::fmt::Display;
|
||||
use std::hash::Hash;
|
||||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::finalize::NotificationTemplate;
|
||||
use crate::finalize::config::Trigger;
|
||||
/// Final runtime configuration for notification, restored and flattened from yaml
|
||||
pub struct NotificationRule{
|
||||
pub struct NotificationRule {
|
||||
pub enabled: bool,
|
||||
|
||||
pub trigger: Vec<Trigger>,
|
||||
pub trigger: Vec<ParsedTrigger>,
|
||||
|
||||
pub template: NotificationTemplate,
|
||||
|
||||
@@ -36,11 +40,55 @@ pub struct NotificationRule{
|
||||
pub retry_backoff: f64,
|
||||
}
|
||||
|
||||
/// Parsed notification trigger type.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ParsedTrigger {
|
||||
OnSuccess,
|
||||
OnFailure,
|
||||
OnFinishOf(Matchable),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Matchable {
|
||||
Glob(GlobSet),
|
||||
Regex(Regex),
|
||||
}
|
||||
|
||||
impl Matchable {
|
||||
fn matches(&self, stage: &StagePhase, substage: &str) -> bool {
|
||||
let stagename = [stage.as_str(), ".", substage].concat();
|
||||
|
||||
match self {
|
||||
Matchable::Glob(s) => s.is_match(stagename.as_str()),
|
||||
Matchable::Regex(r) => r.is_match(stagename.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParsedTrigger {
|
||||
pub fn accept(&self, outcome: &StageOutcome, stage: &StagePhase, substage: &str) -> bool {
|
||||
match self {
|
||||
ParsedTrigger::OnSuccess => outcome == &StageOutcome::PipelineSuccess,
|
||||
ParsedTrigger::OnFailure => outcome == &StageOutcome::PipelineFailure,
|
||||
// TODO: Implement condition checking for OnFinishOf
|
||||
ParsedTrigger::OnFinishOf(m) => m.matches(stage, substage),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NotificationRule {
|
||||
pub fn accept(&self, result: &StageOutcome, stage: &StagePhase, substage: &str) -> bool {
|
||||
self.trigger
|
||||
.iter()
|
||||
.any(|t| t.accept(result, stage, substage))
|
||||
}
|
||||
}
|
||||
|
||||
pub type Priority = u32;
|
||||
pub type Method = String;
|
||||
pub type NotificationRulesMap = HashMap<Priority, HashMap<Method, Vec<NotificationRule>>>;
|
||||
pub type BundledNotificationEvent = Vec<NotificationEvent>;
|
||||
pub type NotificationEventReceiver = tokio::sync::mpsc::Receiver<BundledNotificationEvent>;
|
||||
// pub type BundledNotificationEvent = Vec<NotificationEvent>;
|
||||
pub type NotificationEventReceiver = tokio::sync::mpsc::Receiver<NotificationEvent>;
|
||||
pub type NotificationEventSender = tokio::sync::mpsc::Sender<NotificationEvent>;
|
||||
pub type NotificationPluginMap = HashMap<String, FinalizePlugin>;
|
||||
|
||||
@@ -49,7 +97,7 @@ pub struct NotificationEvent {
|
||||
pub id: Uuid,
|
||||
pub stage: StagePhase,
|
||||
pub substage: String,
|
||||
pub result: StageResult,
|
||||
pub outcome: StageOutcome,
|
||||
pub not_before: DateTime<Utc>,
|
||||
pub priority: u32,
|
||||
pub effective_priority: u32,
|
||||
@@ -89,8 +137,40 @@ impl Ord for NotificationEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delay before retrying an unavailable plugin (5 seconds)
|
||||
pub const NOTIFICATION_NOTREADY_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
pub const NOTIFY_NOTREADY_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
pub const NOTIFY_TEMPORARY_DELAY_FACTOR: f64 = 2.0;
|
||||
pub const NOTIFY_TEMPORARY_DELAY_TIME: f64 = 5.0;
|
||||
pub const NOTIFY_TEMPORARY_MAX_TIME: f64 = f64::MAX;
|
||||
/// Exponential backoff base (2.0 = double each retry)
|
||||
pub const NOTIFICATION_TEMPORARY_DELAY_FACTOR: f64 = 2.0;
|
||||
|
||||
/// Initial backoff delay for temporary errors (5 seconds)
|
||||
pub const NOTIFICATION_TEMPORARY_DELAY_TIME: f64 = 5.0;
|
||||
|
||||
/// Upper bound for backoff delay (unbounded)
|
||||
pub const NOTIFICATION_TEMPORARY_MAX_TIME: f64 = f64::MAX;
|
||||
|
||||
/// Lives recovered after downgrade as fraction of max_lives (0.0 = none)
|
||||
pub const NOTIFICATION_LIFE_RECOVERY_FACTOR: f64 = 0.0;
|
||||
|
||||
pub const NOTIFICATION_DEFAULT_FALLIBLE: bool = false;
|
||||
pub const NOTIFICATION_DEFAULT_MAX_LIVES: u32 = 3;
|
||||
pub const NOTIFICATION_DEFAULT_TIMEOUT_MS: u64 = 30000;
|
||||
pub const NOTIFICATION_DEFAULT_RETRY_BACKOFF: f64 = 2.0;
|
||||
|
||||
#[derive(Default, Debug, Deserialize, Clone)]
|
||||
pub enum NotificationRenderer {
|
||||
#[default]
|
||||
Server,
|
||||
Plugin,
|
||||
Interactive,
|
||||
}
|
||||
|
||||
impl Display for NotificationRenderer {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
NotificationRenderer::Server => write!(f, "server"),
|
||||
NotificationRenderer::Plugin => write!(f, "plugin"),
|
||||
NotificationRenderer::Interactive => write!(f, "interactive"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,20 +21,20 @@
|
||||
//! ```
|
||||
|
||||
pub mod config;
|
||||
pub mod constant;
|
||||
pub mod env;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod security;
|
||||
pub mod stage;
|
||||
pub mod types;
|
||||
|
||||
use crate::{
|
||||
ExecutionContext,
|
||||
cli::Cli,
|
||||
constant::STANDALONE_STATUS_DIR,
|
||||
engine::EventSender,
|
||||
error::PrebakeError,
|
||||
prebake::{security::get_drop_after, stage::PrebakeStage},
|
||||
types::buildstatus::{StageInfo, StagePhase, StageResult, write_stage_status},
|
||||
prebake::{error::PrebakeError, security::get_drop_after, stage::PrebakeStage},
|
||||
};
|
||||
use workshop_engine::EventSender;
|
||||
use chrono::Utc;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -131,26 +131,6 @@ pub async fn prebake(
|
||||
ctx.task_id = format!("{}-{}-bootstrap", cli.pipeline, cli.build_id);
|
||||
let osinfo = os_info::get();
|
||||
let result = stage::bootstrap::bootstrap(&prebake, &osinfo, &ctx, &event_tx).await;
|
||||
let stage_result = if result.is_ok() {
|
||||
StageResult::Success
|
||||
} else {
|
||||
StageResult::Failure
|
||||
};
|
||||
let _ = write_stage_status(
|
||||
STANDALONE_STATUS_DIR,
|
||||
StageInfo {
|
||||
name: "bootstrap".to_string(),
|
||||
phase: StagePhase::Prebake,
|
||||
substage: "bootstrap".to_string(),
|
||||
result: stage_result,
|
||||
duration_ms: Utc::now()
|
||||
.signed_duration_since(stage_start)
|
||||
.num_milliseconds() as u64,
|
||||
started_at: stage_start,
|
||||
finished_at: Utc::now(),
|
||||
error_message: result.as_ref().err().map(|e| e.to_string()),
|
||||
},
|
||||
);
|
||||
result?;
|
||||
}
|
||||
|
||||
@@ -165,26 +145,6 @@ pub async fn prebake(
|
||||
None => None,
|
||||
};
|
||||
let result = stage::hook::hook(earlyhook, &ctx, event_tx.clone()).await;
|
||||
let stage_result = if result.is_ok() {
|
||||
StageResult::Success
|
||||
} else {
|
||||
StageResult::Failure
|
||||
};
|
||||
let _ = write_stage_status(
|
||||
STANDALONE_STATUS_DIR,
|
||||
StageInfo {
|
||||
name: "earlyhook".to_string(),
|
||||
phase: StagePhase::Prebake,
|
||||
substage: "earlyhook".to_string(),
|
||||
result: stage_result,
|
||||
duration_ms: Utc::now()
|
||||
.signed_duration_since(stage_start)
|
||||
.num_milliseconds() as u64,
|
||||
started_at: stage_start,
|
||||
finished_at: Utc::now(),
|
||||
error_message: result.as_ref().err().map(|e| e.to_string()),
|
||||
},
|
||||
);
|
||||
result?;
|
||||
}
|
||||
|
||||
@@ -193,10 +153,10 @@ pub async fn prebake(
|
||||
.config
|
||||
.as_ref()
|
||||
.map(|c| c.repology_endpoint)
|
||||
.unwrap_or(crate::types::repology::RepologyEndpoint::Default);
|
||||
.unwrap_or(workshop_engine::RepologyEndpoint::Default);
|
||||
|
||||
let osinfo = os_info::get();
|
||||
let detected_pm = crate::engine::pm::detect(&osinfo).map(|pm| pm.name().to_string());
|
||||
let detected_pm = workshop_engine::pm::detect(&osinfo).map(|pm| pm.name().to_string());
|
||||
|
||||
let upm_sys_deps = if let Some(user) = &dependencies.user {
|
||||
if stage <= PrebakeStage::DepsSystem {
|
||||
@@ -213,7 +173,7 @@ pub async fn prebake(
|
||||
if !upm_sys_deps.deps.is_empty() {
|
||||
if let Some(pm_name) = &detected_pm {
|
||||
for (upm_name, sys_deps) in &upm_sys_deps.deps {
|
||||
let resolved = crate::engine::repology::resolve_package_names(
|
||||
let resolved = workshop_engine::repology::resolve_package_names(
|
||||
&sys_deps.packages,
|
||||
sys_deps.mapped,
|
||||
&endpoint,
|
||||
@@ -254,26 +214,6 @@ pub async fn prebake(
|
||||
ctx.task_id = format!("{}-{}-depssystem", cli.pipeline, cli.build_id);
|
||||
let result =
|
||||
stage::depssystem::depssystem(&system_config, &ctx, event_tx.clone()).await;
|
||||
let stage_result = if result.is_ok() {
|
||||
StageResult::Success
|
||||
} else {
|
||||
StageResult::Failure
|
||||
};
|
||||
let _ = write_stage_status(
|
||||
STANDALONE_STATUS_DIR,
|
||||
StageInfo {
|
||||
name: "depssystem".to_string(),
|
||||
phase: StagePhase::Prebake,
|
||||
substage: "depssystem".to_string(),
|
||||
result: stage_result,
|
||||
duration_ms: Utc::now()
|
||||
.signed_duration_since(stage_start)
|
||||
.num_milliseconds() as u64,
|
||||
started_at: stage_start,
|
||||
finished_at: Utc::now(),
|
||||
error_message: result.as_ref().err().map(|e| e.to_string()),
|
||||
},
|
||||
);
|
||||
result?;
|
||||
}
|
||||
|
||||
@@ -285,26 +225,6 @@ pub async fn prebake(
|
||||
prebake_drop_privilege(PrebakeStage::DepsUser, dropafter, target_user, &mut ctx)?;
|
||||
ctx.task_id = format!("{}-{}-depsuser", cli.pipeline, cli.build_id);
|
||||
let result = stage::depsuser::depsuser(&user, &ctx, event_tx.clone(), &endpoint).await;
|
||||
let stage_result = if result.is_ok() {
|
||||
StageResult::Success
|
||||
} else {
|
||||
StageResult::Failure
|
||||
};
|
||||
let _ = write_stage_status(
|
||||
STANDALONE_STATUS_DIR,
|
||||
StageInfo {
|
||||
name: "depsuser".to_string(),
|
||||
phase: StagePhase::Prebake,
|
||||
substage: "depsuser".to_string(),
|
||||
result: stage_result,
|
||||
duration_ms: Utc::now()
|
||||
.signed_duration_since(stage_start)
|
||||
.num_milliseconds() as u64,
|
||||
started_at: stage_start,
|
||||
finished_at: Utc::now(),
|
||||
error_message: result.as_ref().err().map(|e| e.to_string()),
|
||||
},
|
||||
);
|
||||
result?;
|
||||
}
|
||||
}
|
||||
@@ -320,26 +240,6 @@ pub async fn prebake(
|
||||
None => None,
|
||||
};
|
||||
let result = stage::hook::hook(latehook, &ctx, event_tx.clone()).await;
|
||||
let stage_result = if result.is_ok() {
|
||||
StageResult::Success
|
||||
} else {
|
||||
StageResult::Failure
|
||||
};
|
||||
let _ = write_stage_status(
|
||||
STANDALONE_STATUS_DIR,
|
||||
StageInfo {
|
||||
name: "latehook".to_string(),
|
||||
phase: StagePhase::Prebake,
|
||||
substage: "latehook".to_string(),
|
||||
result: stage_result,
|
||||
duration_ms: Utc::now()
|
||||
.signed_duration_since(stage_start)
|
||||
.num_milliseconds() as u64,
|
||||
started_at: stage_start,
|
||||
finished_at: Utc::now(),
|
||||
error_message: result.as_ref().err().map(|e| e.to_string()),
|
||||
},
|
||||
);
|
||||
result?;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@ use serde_yaml::Value;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::types::architecture::Architecture;
|
||||
use crate::types::builderconfig::{
|
||||
use crate::prebake::types::architecture::Architecture;
|
||||
use crate::prebake::types::builderconfig::{
|
||||
BaremetalConfig, BuilderType, CustomConfig, DockerConfig, FirecrackerConfig,
|
||||
};
|
||||
use crate::types::cache::{CacheDirectory, CacheStrategy};
|
||||
use crate::types::command::CustomCommand;
|
||||
use crate::types::memsize::MemSize;
|
||||
use crate::types::repology::RepologyEndpoint;
|
||||
use crate::prebake::types::cache::{CacheDirectory, CacheStrategy};
|
||||
use crate::prebake::types::memsize::MemSize;
|
||||
use workshop_engine::{CustomCommand, RepologyEndpoint};
|
||||
|
||||
pub const VERSION_REQUIREMENT: &str = "^1.0";
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub const BOOTSTRAP_SCRIPT_PATH: &str = "/bootstrap.sh";
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
use crate::prebake::config::PrebakeConfig;
|
||||
use crate::types::builderconfig::DockerConfig;
|
||||
use crate::prebake::types::builderconfig::DockerConfig;
|
||||
use anyhow::Context;
|
||||
use bollard::{
|
||||
API_DEFAULT_VERSION, Docker, body_full,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
use workshop_engine::{
|
||||
ExecutionError,
|
||||
error::{
|
||||
EXITCODE_IO_ERROR, EXITCODE_PARSE_ERROR, EXITCODE_PRIV_DROP_FAILED,
|
||||
HasExitCode,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PrebakeError {
|
||||
#[error("Privilege drop failed: {0}")]
|
||||
PrivDropFailed(String),
|
||||
|
||||
#[error("User not found: {0}")]
|
||||
UserNotFound(String),
|
||||
|
||||
#[error("Invalid privilege state: current_uid={current_uid}, target_uid={target_uid}")]
|
||||
InvalidPrivilegeState { current_uid: u32, target_uid: u32 },
|
||||
|
||||
#[error("Invalid stage: current={current}, expected={expected}")]
|
||||
InvalidStage { current: String, expected: String },
|
||||
|
||||
#[error("Failed to validate prebake.yml.")]
|
||||
ValidateError(),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
#[error("YAML parse error: {0}")]
|
||||
YamlParseError(#[from] serde_yaml::Error),
|
||||
|
||||
#[error("Bad prebake.yml: {0}")]
|
||||
ConfigError(String),
|
||||
}
|
||||
|
||||
impl HasExitCode for PrebakeError {
|
||||
fn exit_code(&self) -> i32 {
|
||||
match self {
|
||||
PrebakeError::PrivDropFailed(_) => EXITCODE_PRIV_DROP_FAILED,
|
||||
PrebakeError::UserNotFound(_) => EXITCODE_PRIV_DROP_FAILED,
|
||||
PrebakeError::InvalidPrivilegeState { .. } => EXITCODE_PRIV_DROP_FAILED,
|
||||
PrebakeError::ValidateError() => EXITCODE_PARSE_ERROR,
|
||||
PrebakeError::InvalidStage { .. } => EXITCODE_PRIV_DROP_FAILED,
|
||||
PrebakeError::IoError(_) => EXITCODE_IO_ERROR,
|
||||
PrebakeError::YamlParseError(_) => EXITCODE_PARSE_ERROR,
|
||||
PrebakeError::ConfigError(_) => EXITCODE_PARSE_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum BootstrapError {
|
||||
#[error("Failed to write bootstrap script: {0}")]
|
||||
IOError(#[from] std::io::Error),
|
||||
|
||||
#[error("Failed to find bootstrap.sh: {0}")]
|
||||
ScriptNotFound(PathBuf),
|
||||
|
||||
#[error("Failed to execute bootstrap.sh: {0}")]
|
||||
ExecutionError(#[from] ExecutionError),
|
||||
|
||||
#[error("Failed to create user {0}: {1}")]
|
||||
UserCreationFailed(String, String),
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::engine::{ExecutionEvent, StreamType};
|
||||
use workshop_engine::{ExecutionEvent, StreamType};
|
||||
|
||||
pub async fn event_receiver(
|
||||
mut rx: tokio::sync::mpsc::Receiver<ExecutionEvent>,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::ExecutionContext;
|
||||
use crate::error::PrebakeError;
|
||||
use crate::prebake::error::PrebakeError;
|
||||
use crate::prebake::config::Security;
|
||||
use crate::prebake::stage::PrebakeStage;
|
||||
use privdrop::PrivDrop;
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
//! and the target operating system, supporting multiple package managers.
|
||||
|
||||
use crate::ExecutionContext;
|
||||
use crate::constant::BOOTSTRAP_SCRIPT_PATH;
|
||||
use crate::engine::{Engine, EventSender, pm};
|
||||
use crate::error::BootstrapError;
|
||||
use crate::prebake::constant::BOOTSTRAP_SCRIPT_PATH;
|
||||
use workshop_engine::{Engine, EventSender, pm};
|
||||
use crate::prebake::error::BootstrapError;
|
||||
use crate::prebake::config::Bootstrap;
|
||||
use crate::prebake::config::PrebakeConfig;
|
||||
use std::fs::File;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::ExecutionContext;
|
||||
use crate::engine::{Engine, EventSender, pm};
|
||||
use crate::error::DependencyError;
|
||||
use workshop_engine::{Engine, EventSender, pm, error::DependencyError};
|
||||
use crate::prebake::config::SystemDependency;
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use serde_yaml::Value;
|
||||
|
||||
use crate::ExecutionContext;
|
||||
use crate::engine::{EventSender, upm};
|
||||
use crate::error::DependencyError;
|
||||
use crate::types::repology::RepologyEndpoint;
|
||||
use workshop_engine::{EventSender, upm, error::DependencyError, RepologyEndpoint};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::prebake::config::PrebakeConfig;
|
||||
use crate::types::builderconfig::BuilderType;
|
||||
use crate::prebake::types::builderconfig::BuilderType;
|
||||
use config::Config;
|
||||
|
||||
pub use crate::prebake::env::baremetal::prepare_baremetal;
|
||||
|
||||
@@ -13,9 +13,7 @@
|
||||
//! and timeout configurations.
|
||||
|
||||
use crate::ExecutionContext;
|
||||
use crate::engine::types::DeltaExecutionContext;
|
||||
use crate::engine::{Engine, EventSender, ExecutionError};
|
||||
use crate::types::command::CustomCommand;
|
||||
use workshop_engine::{DeltaExecutionContext, Engine, EventSender, ExecutionError, CustomCommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Executes a sequence of hook commands.
|
||||
@@ -59,6 +57,7 @@ pub async fn hook(
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: EventSender,
|
||||
) -> Result<(), ExecutionError> {
|
||||
dbg!(ctx);
|
||||
let hook = match hook {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
@@ -80,6 +79,7 @@ pub async fn hook(
|
||||
let result = engine
|
||||
.execute_command_with_delta(&hook_cmd.command, ctx, &event_tx, &deltactx)
|
||||
.await;
|
||||
dbg!(&result);
|
||||
if let Err(e) = result {
|
||||
log::error!("Hook {} failed: {:?}", index, e);
|
||||
return Err(e);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod architecture;
|
||||
pub mod builderconfig;
|
||||
pub mod cache;
|
||||
pub mod memsize;
|
||||
@@ -0,0 +1,232 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Supported build architectures for cross-compilation.
|
||||
///
|
||||
/// Variants like `Amd64`/`Aarch64`/`Loong64` are normalized to their
|
||||
/// canonical forms (`X86_64`/`Arm64`/`Loongarch64`) during processing.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
|
||||
pub enum Architecture {
|
||||
/// No architecture specified (used as default/placeholder).
|
||||
#[default]
|
||||
NoArch,
|
||||
/// 64-bit x86 architecture.
|
||||
X86_64,
|
||||
/// Alias for X86_64, normalized during processing.
|
||||
Amd64,
|
||||
/// 64-bit ARM architecture.
|
||||
Arm64,
|
||||
/// Alias for Arm64, normalized during processing.
|
||||
Aarch64,
|
||||
/// 64-bit RISC-V architecture.
|
||||
Riscv64,
|
||||
/// Loongson 64-bit architecture (canonical form).
|
||||
Loongarch64,
|
||||
/// Alias for Loongarch64, normalized during processing.
|
||||
Loong64,
|
||||
}
|
||||
|
||||
impl Architecture {
|
||||
/// Returns the normalized canonical form of this architecture.
|
||||
///
|
||||
/// - `Amd64` → `X86_64`
|
||||
/// - `Aarch64` → `Arm64`
|
||||
/// - `Loong64` → `Loongarch64`
|
||||
/// - All other variants are returned unchanged.
|
||||
pub fn normalized(&self) -> Architecture {
|
||||
match self {
|
||||
Architecture::Amd64 => Architecture::X86_64,
|
||||
Architecture::Aarch64 => Architecture::Arm64,
|
||||
Architecture::Loong64 => Architecture::Loongarch64,
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a static string representation of this architecture variant.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Architecture::NoArch => "noarch",
|
||||
Architecture::X86_64 => "x86_64",
|
||||
Architecture::Amd64 => "amd64",
|
||||
Architecture::Arm64 => "arm64",
|
||||
Architecture::Aarch64 => "aarch64",
|
||||
Architecture::Riscv64 => "riscv64",
|
||||
Architecture::Loongarch64 => "loongarch64",
|
||||
Architecture::Loong64 => "loong64",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Architecture {
|
||||
type Err = String;
|
||||
|
||||
/// Parses an architecture string case-insensitively.
|
||||
///
|
||||
/// Accepts both canonical and alias forms (e.g., "amd64" and "x86_64").
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for unknown architecture strings.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// # use workshop_baker::prebake::types::architecture::Architecture;
|
||||
/// use std::str::FromStr;
|
||||
///
|
||||
/// let arch = Architecture::from_str("amd64").unwrap();
|
||||
/// assert_eq!(arch, Architecture::Amd64);
|
||||
///
|
||||
/// let unknown = Architecture::from_str("unknown").unwrap_err();
|
||||
/// assert!(unknown.contains("Unknown architecture"));
|
||||
/// ```
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"noarch" => Ok(Architecture::NoArch),
|
||||
"x86_64" | "amd64" => Ok(Architecture::Amd64),
|
||||
"arm64" | "aarch64" => Ok(Architecture::Aarch64),
|
||||
"riscv64" => Ok(Architecture::Riscv64),
|
||||
"loongarch64" | "loong64" => Ok(Architecture::Loong64),
|
||||
_ => Err(format!("Unknown architecture: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// normalized() tests
|
||||
|
||||
#[test]
|
||||
fn test_normalized_amd64_to_x86_64() {
|
||||
assert_eq!(Architecture::Amd64.normalized(), Architecture::X86_64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalized_aarch64_to_arm64() {
|
||||
assert_eq!(Architecture::Aarch64.normalized(), Architecture::Arm64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalized_loong64_to_loongarch64() {
|
||||
assert_eq!(
|
||||
Architecture::Loong64.normalized(),
|
||||
Architecture::Loongarch64
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalized_noarch_unchanged() {
|
||||
assert_eq!(Architecture::NoArch.normalized(), Architecture::NoArch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalized_x86_64_unchanged() {
|
||||
assert_eq!(Architecture::X86_64.normalized(), Architecture::X86_64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalized_arm64_unchanged() {
|
||||
assert_eq!(Architecture::Arm64.normalized(), Architecture::Arm64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalized_riscv64_unchanged() {
|
||||
assert_eq!(Architecture::Riscv64.normalized(), Architecture::Riscv64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalized_loongarch64_unchanged() {
|
||||
assert_eq!(
|
||||
Architecture::Loongarch64.normalized(),
|
||||
Architecture::Loongarch64
|
||||
);
|
||||
}
|
||||
|
||||
// as_str() tests
|
||||
|
||||
#[test]
|
||||
fn test_as_str_all_variants() {
|
||||
assert_eq!(Architecture::NoArch.as_str(), "noarch");
|
||||
assert_eq!(Architecture::X86_64.as_str(), "x86_64");
|
||||
assert_eq!(Architecture::Amd64.as_str(), "amd64");
|
||||
assert_eq!(Architecture::Arm64.as_str(), "arm64");
|
||||
assert_eq!(Architecture::Aarch64.as_str(), "aarch64");
|
||||
assert_eq!(Architecture::Riscv64.as_str(), "riscv64");
|
||||
assert_eq!(Architecture::Loongarch64.as_str(), "loongarch64");
|
||||
assert_eq!(Architecture::Loong64.as_str(), "loong64");
|
||||
}
|
||||
|
||||
// FromStr tests
|
||||
|
||||
#[test]
|
||||
fn test_from_str_valid_canonical() {
|
||||
assert_eq!(
|
||||
"noarch".parse::<Architecture>().unwrap(),
|
||||
Architecture::NoArch
|
||||
);
|
||||
assert_eq!(
|
||||
"x86_64".parse::<Architecture>().unwrap(),
|
||||
Architecture::Amd64
|
||||
);
|
||||
assert_eq!(
|
||||
"arm64".parse::<Architecture>().unwrap(),
|
||||
Architecture::Aarch64
|
||||
);
|
||||
assert_eq!(
|
||||
"riscv64".parse::<Architecture>().unwrap(),
|
||||
Architecture::Riscv64
|
||||
);
|
||||
assert_eq!(
|
||||
"loongarch64".parse::<Architecture>().unwrap(),
|
||||
Architecture::Loong64
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_str_valid_aliases() {
|
||||
assert_eq!(
|
||||
"amd64".parse::<Architecture>().unwrap(),
|
||||
Architecture::Amd64
|
||||
);
|
||||
assert_eq!(
|
||||
"aarch64".parse::<Architecture>().unwrap(),
|
||||
Architecture::Aarch64
|
||||
);
|
||||
assert_eq!(
|
||||
"loong64".parse::<Architecture>().unwrap(),
|
||||
Architecture::Loong64
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_str_case_insensitive() {
|
||||
assert_eq!(
|
||||
"X86_64".parse::<Architecture>().unwrap(),
|
||||
Architecture::Amd64
|
||||
);
|
||||
assert_eq!(
|
||||
"ARM64".parse::<Architecture>().unwrap(),
|
||||
Architecture::Aarch64
|
||||
);
|
||||
assert_eq!(
|
||||
"RISCV64".parse::<Architecture>().unwrap(),
|
||||
Architecture::Riscv64
|
||||
);
|
||||
assert_eq!(
|
||||
"NoArch".parse::<Architecture>().unwrap(),
|
||||
Architecture::NoArch
|
||||
);
|
||||
assert_eq!(
|
||||
"LoongArch64".parse::<Architecture>().unwrap(),
|
||||
Architecture::Loong64
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_str_unknown_error() {
|
||||
let result = "unknown_arch".parse::<Architecture>();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unknown architecture"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Build environment type.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BuilderType {
|
||||
/// Docker-based build environment.
|
||||
Docker,
|
||||
/// Firecracker microVM-based build environment.
|
||||
Firecracker,
|
||||
/// Custom build environment with user-defined scripts.
|
||||
Custom,
|
||||
/// Bare metal build environment.
|
||||
Baremetal,
|
||||
}
|
||||
|
||||
/// Configuration for build environments.
|
||||
///
|
||||
/// Uses untagged representation to accept any variant without a type key.
|
||||
/// Automatically detects the appropriate variant based on YAML structure.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum BuilderConfig {
|
||||
/// Docker-specific build configuration.
|
||||
Docker(DockerConfig),
|
||||
/// Firecracker-specific build configuration.
|
||||
Firecracker(FirecrackerConfig),
|
||||
/// Custom build environment with setup/cleanup scripts.
|
||||
Custom(CustomConfig),
|
||||
/// Bare metal build configuration.
|
||||
Baremetal(BaremetalConfig),
|
||||
}
|
||||
|
||||
/// Docker build environment configuration.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct DockerConfig {
|
||||
/// Docker image to use for the build.
|
||||
#[serde(default)]
|
||||
pub image: Option<String>,
|
||||
|
||||
/// Path to Dockerfile for the build.
|
||||
#[serde(default)]
|
||||
pub dockerfile: Option<String>,
|
||||
|
||||
/// Build arguments passed to Docker.
|
||||
#[serde(default)]
|
||||
pub build_args: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// Firecracker microVM build configuration (empty placeholder).
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct FirecrackerConfig {}
|
||||
|
||||
/// Custom build environment with user-defined setup and cleanup scripts.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct CustomConfig {
|
||||
/// Identifier for the custom builder.
|
||||
pub name: String,
|
||||
|
||||
/// Script to run during environment setup.
|
||||
pub setup_script: String,
|
||||
|
||||
/// Script to run during environment cleanup.
|
||||
pub cleanup_script: String,
|
||||
}
|
||||
|
||||
/// Bare metal build configuration (empty placeholder).
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct BaremetalConfig {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_builder_type_serde_roundtrip() {
|
||||
for builder_type in [
|
||||
BuilderType::Docker,
|
||||
BuilderType::Firecracker,
|
||||
BuilderType::Custom,
|
||||
BuilderType::Baremetal,
|
||||
] {
|
||||
let yaml = serde_yaml::to_string(&builder_type).unwrap();
|
||||
let deserialized: BuilderType = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(deserialized, builder_type);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_config_serde() {
|
||||
let config = DockerConfig {
|
||||
image: Some("rust:latest".to_string()),
|
||||
dockerfile: Some("Dockerfile".to_string()),
|
||||
build_args: Some(HashMap::from([(
|
||||
"RUST_BACKTRACE".to_string(),
|
||||
"1".to_string(),
|
||||
)])),
|
||||
};
|
||||
let yaml = serde_yaml::to_string(&config).unwrap();
|
||||
let deserialized: DockerConfig = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(deserialized.image, Some("rust:latest".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builder_config_untagged_docker() {
|
||||
let yaml = "image: rust:latest";
|
||||
let config: BuilderConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
match config {
|
||||
BuilderConfig::Docker(dc) => assert_eq!(dc.image, Some("rust:latest".to_string())),
|
||||
_ => panic!("Expected Docker variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builder_config_untagged_docker_first() {
|
||||
let yaml = "image: rust:latest\nname: my-builder";
|
||||
let config: BuilderConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert!(matches!(config, BuilderConfig::Docker(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builder_config_untagged_empty_map() {
|
||||
let yaml = "{}";
|
||||
let config: BuilderConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert!(matches!(config, BuilderConfig::Docker(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Cache directory configuration with strategy and compression mode.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct CacheDirectory {
|
||||
/// Path to the cache directory.
|
||||
pub path: String,
|
||||
/// Cache strategy type. Defaults to `Always`.
|
||||
#[serde(default = "default_strategy")]
|
||||
pub strategy: CacheStrategyType,
|
||||
/// Compression mode for cached artifacts. Defaults to `Zstd`.
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: CacheMode,
|
||||
}
|
||||
|
||||
fn default_strategy() -> CacheStrategyType {
|
||||
CacheStrategyType::Always
|
||||
}
|
||||
fn default_mode() -> CacheMode {
|
||||
CacheMode::Zstd
|
||||
}
|
||||
|
||||
/// Type of cache strategy.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CacheStrategyType {
|
||||
/// Always cache, read and write on every operation.
|
||||
Always,
|
||||
/// Read-only cache, never writes new entries.
|
||||
Readonly,
|
||||
/// Clean the cache according to cleanup policy.
|
||||
Clean,
|
||||
/// Never use cache.
|
||||
Never,
|
||||
}
|
||||
|
||||
/// Compression mode for cached artifacts.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CacheMode {
|
||||
/// Gzip compression.
|
||||
Gzip,
|
||||
/// Zstd compression.
|
||||
Zstd,
|
||||
/// No compression.
|
||||
None,
|
||||
/// Directory mode.
|
||||
Dir,
|
||||
}
|
||||
|
||||
/// Cache strategy with size and TTL limits.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct CacheStrategy {
|
||||
/// Time-to-live in days. Defaults to 30.
|
||||
#[serde(default = "default_ttl_days", rename = "ttl_days")]
|
||||
pub ttl_days: u32,
|
||||
/// Maximum cache size in gigabytes. Defaults to 20.
|
||||
#[serde(default = "default_max_size_gb", rename = "max_size_gb")]
|
||||
pub max_size_gb: u32,
|
||||
/// Cleanup policy when cache exceeds limits. Defaults to `Lru`.
|
||||
#[serde(default = "default_cleanup_policy", rename = "cleanup_policy")]
|
||||
pub cleanup_policy: CleanupPolicy,
|
||||
}
|
||||
|
||||
fn default_ttl_days() -> u32 {
|
||||
30
|
||||
}
|
||||
fn default_max_size_gb() -> u32 {
|
||||
20
|
||||
}
|
||||
fn default_cleanup_policy() -> CleanupPolicy {
|
||||
CleanupPolicy::Lru
|
||||
}
|
||||
|
||||
/// Policy for cleaning up cache when limits are exceeded.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CleanupPolicy {
|
||||
/// Least Recently Used eviction.
|
||||
Lru,
|
||||
/// First In First Out eviction.
|
||||
Fifo,
|
||||
/// Evict by size.
|
||||
Size,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// CacheDirectory default tests
|
||||
|
||||
#[test]
|
||||
fn test_cache_directory_default_strategy() {
|
||||
let json = r#"{"path": "/tmp/cache"}"#;
|
||||
let cache: CacheDirectory = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(cache.strategy, CacheStrategyType::Always);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_directory_default_mode() {
|
||||
let json = r#"{"path": "/tmp/cache"}"#;
|
||||
let cache: CacheDirectory = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(cache.mode, CacheMode::Zstd);
|
||||
}
|
||||
|
||||
// CacheStrategy default tests
|
||||
|
||||
#[test]
|
||||
fn test_cache_strategy_default_ttl_days() {
|
||||
let strategy_json = r#"{}"#;
|
||||
let strategy: CacheStrategy = serde_json::from_str(strategy_json).unwrap();
|
||||
assert_eq!(strategy.ttl_days, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_strategy_default_max_size_gb() {
|
||||
let strategy_json = r#"{}"#;
|
||||
let strategy: CacheStrategy = serde_json::from_str(strategy_json).unwrap();
|
||||
assert_eq!(strategy.max_size_gb, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_strategy_default_cleanup_policy() {
|
||||
let strategy_json = r#"{}"#;
|
||||
let strategy: CacheStrategy = serde_json::from_str(strategy_json).unwrap();
|
||||
assert_eq!(strategy.cleanup_policy, CleanupPolicy::Lru);
|
||||
}
|
||||
|
||||
// Serde round-trip tests for CacheStrategyType
|
||||
|
||||
#[test]
|
||||
fn test_cache_strategy_type_always_roundtrip() {
|
||||
let val = CacheStrategyType::Always;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"always\"");
|
||||
let parsed: CacheStrategyType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_strategy_type_readonly_roundtrip() {
|
||||
let val = CacheStrategyType::Readonly;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"readonly\"");
|
||||
let parsed: CacheStrategyType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_strategy_type_clean_roundtrip() {
|
||||
let val = CacheStrategyType::Clean;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"clean\"");
|
||||
let parsed: CacheStrategyType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_strategy_type_never_roundtrip() {
|
||||
let val = CacheStrategyType::Never;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"never\"");
|
||||
let parsed: CacheStrategyType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
// Serde round-trip tests for CacheMode
|
||||
|
||||
#[test]
|
||||
fn test_cache_mode_gzip_roundtrip() {
|
||||
let val = CacheMode::Gzip;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"gzip\"");
|
||||
let parsed: CacheMode = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_mode_zstd_roundtrip() {
|
||||
let val = CacheMode::Zstd;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"zstd\"");
|
||||
let parsed: CacheMode = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_mode_none_roundtrip() {
|
||||
let val = CacheMode::None;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"none\"");
|
||||
let parsed: CacheMode = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_mode_dir_roundtrip() {
|
||||
let val = CacheMode::Dir;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"dir\"");
|
||||
let parsed: CacheMode = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
// Serde round-trip tests for CleanupPolicy
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_policy_lru_roundtrip() {
|
||||
let val = CleanupPolicy::Lru;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"lru\"");
|
||||
let parsed: CleanupPolicy = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_policy_fifo_roundtrip() {
|
||||
let val = CleanupPolicy::Fifo;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"fifo\"");
|
||||
let parsed: CleanupPolicy = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_policy_size_roundtrip() {
|
||||
let val = CleanupPolicy::Size;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"size\"");
|
||||
let parsed: CleanupPolicy = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
/// Socket type for executor connections.
|
||||
pub enum ExecutorSocket {
|
||||
/// Unix domain socket at the given path.
|
||||
Unix(String),
|
||||
/// TCP socket at the given host:port address.
|
||||
Tcp(String),
|
||||
/// Dry-run mode (discards all output).
|
||||
DryRun,
|
||||
}
|
||||
|
||||
/// Parses a socket address string and returns the appropriate ExecutorSocket variant.
|
||||
///
|
||||
/// Supports the following formats:
|
||||
/// - `unix://<path>` - Unix domain socket
|
||||
/// - `/<absolute/path>` - Unix domain socket (shorthand)
|
||||
/// - `tcp://<host>:<port>` - TCP socket
|
||||
/// - `<host>:<port>` - TCP socket (shorthand, no tcp:// prefix)
|
||||
/// - `dryrun` - Dry-run mode
|
||||
pub fn get_socket_addr(socket_addr: String) -> anyhow::Result<ExecutorSocket> {
|
||||
let socket = if socket_addr.starts_with("unix://") {
|
||||
ExecutorSocket::Unix(socket_addr.strip_prefix("unix://").unwrap().to_string())
|
||||
@@ -19,6 +32,56 @@ pub fn get_socket_addr(socket_addr: String) -> anyhow::Result<ExecutorSocket> {
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_unix_protocol_prefix() {
|
||||
let socket = get_socket_addr("unix:///var/run/executor.sock".to_string()).unwrap();
|
||||
match socket {
|
||||
ExecutorSocket::Unix(path) => assert_eq!(path, "/var/run/executor.sock"),
|
||||
_ => panic!("Expected Unix socket"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unix_absolute_path() {
|
||||
let socket = get_socket_addr("/var/run/executor.sock".to_string()).unwrap();
|
||||
match socket {
|
||||
ExecutorSocket::Unix(path) => assert_eq!(path, "/var/run/executor.sock"),
|
||||
_ => panic!("Expected Unix socket"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tcp_protocol_prefix() {
|
||||
let socket = get_socket_addr("tcp://localhost:8080".to_string()).unwrap();
|
||||
match socket {
|
||||
ExecutorSocket::Tcp(addr) => assert_eq!(addr, "localhost:8080"),
|
||||
_ => panic!("Expected Tcp socket"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dryrun() {
|
||||
let socket = get_socket_addr("dryrun".to_string()).unwrap();
|
||||
match socket {
|
||||
ExecutorSocket::DryRun => {}
|
||||
_ => panic!("Expected DryRun socket"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tcp_fallback() {
|
||||
let socket = get_socket_addr("localhost:8080".to_string()).unwrap();
|
||||
match socket {
|
||||
ExecutorSocket::Tcp(addr) => assert_eq!(addr, "localhost:8080"),
|
||||
_ => panic!("Expected Tcp socket (fallback)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn establish_connection(
|
||||
socket_addr: &ExecutorSocket,
|
||||
) -> anyhow::Result<Box<dyn tokio::io::AsyncWrite + Unpin>> {
|
||||
|
||||
@@ -1,9 +1 @@
|
||||
pub mod architecture;
|
||||
pub mod builderconfig;
|
||||
pub mod buildstatus;
|
||||
pub mod cache;
|
||||
pub mod command;
|
||||
pub mod compression;
|
||||
pub mod memsize;
|
||||
pub mod repology;
|
||||
pub mod time;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
|
||||
pub enum Architecture {
|
||||
#[default]
|
||||
NoArch,
|
||||
X86_64,
|
||||
Amd64, // Same as X86_64, normalized when processing
|
||||
Arm64,
|
||||
Aarch64, // Same as Arm64, normalized when processing
|
||||
Riscv64,
|
||||
Loongarch64,
|
||||
Loong64, // Same as Loongarch64, normalized when processing, discarding old world Loongarch for simplicity
|
||||
}
|
||||
|
||||
impl Architecture {
|
||||
pub fn normalized(&self) -> Architecture {
|
||||
match self {
|
||||
Architecture::Amd64 => Architecture::X86_64,
|
||||
Architecture::Aarch64 => Architecture::Arm64,
|
||||
Architecture::Loong64 => Architecture::Loongarch64,
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Architecture::NoArch => "noarch",
|
||||
Architecture::X86_64 => "x86_64",
|
||||
Architecture::Amd64 => "amd64",
|
||||
Architecture::Arm64 => "arm64",
|
||||
Architecture::Aarch64 => "aarch64",
|
||||
Architecture::Riscv64 => "riscv64",
|
||||
Architecture::Loongarch64 => "loongarch64",
|
||||
Architecture::Loong64 => "loong64",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Architecture {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"noarch" => Ok(Architecture::NoArch),
|
||||
"x86_64" | "amd64" => Ok(Architecture::Amd64),
|
||||
"arm64" | "aarch64" => Ok(Architecture::Aarch64),
|
||||
"riscv64" => Ok(Architecture::Riscv64),
|
||||
"loongarch64" | "loong64" => Ok(Architecture::Loong64),
|
||||
_ => Err(format!("Unknown architecture: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BuilderType {
|
||||
Docker,
|
||||
Firecracker,
|
||||
Custom,
|
||||
Baremetal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum BuilderConfig {
|
||||
Docker(DockerConfig),
|
||||
Firecracker(FirecrackerConfig),
|
||||
Custom(CustomConfig),
|
||||
Baremetal(BaremetalConfig),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct DockerConfig {
|
||||
#[serde(default)]
|
||||
pub image: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dockerfile: Option<String>,
|
||||
#[serde(default)]
|
||||
pub build_args: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct FirecrackerConfig {}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct CustomConfig {
|
||||
pub name: String,
|
||||
pub setup_script: String,
|
||||
pub cleanup_script: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct BaremetalConfig {}
|
||||
@@ -1,10 +1,14 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Pipeline execution phase.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum StagePhase {
|
||||
/// Prebake phase: environment setup and dependency installation.
|
||||
Prebake,
|
||||
/// Bake phase: main build and compilation.
|
||||
Bake,
|
||||
/// Finalize phase: packaging and deployment.
|
||||
Finalize,
|
||||
}
|
||||
|
||||
@@ -18,127 +22,125 @@ impl std::fmt::Display for StagePhase {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum StageResult {
|
||||
Success,
|
||||
Failure,
|
||||
Canceled,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StageResult {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
impl StagePhase {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
StageResult::Success => write!(f, "success"),
|
||||
StageResult::Failure => write!(f, "failure"),
|
||||
StageResult::Canceled => write!(f, "canceled"),
|
||||
StageResult::Skipped => write!(f, "skipped"),
|
||||
StagePhase::Prebake => "prebake",
|
||||
StagePhase::Bake => "bake",
|
||||
StagePhase::Finalize => "finalize",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a pipeline stage execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum StageOutcome {
|
||||
/// Stage completed successfully.
|
||||
Success,
|
||||
/// Stage failed during execution.
|
||||
Failure,
|
||||
/// Stage was canceled before completion.
|
||||
Canceled,
|
||||
/// Stage was skipped.
|
||||
Skipped,
|
||||
/// Pipeline succeeded.
|
||||
PipelineSuccess,
|
||||
/// Pipeline failed.
|
||||
PipelineFailure,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StageOutcome {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
StageOutcome::Success => write!(f, "success"),
|
||||
StageOutcome::Failure => write!(f, "failure"),
|
||||
StageOutcome::Canceled => write!(f, "canceled"),
|
||||
StageOutcome::Skipped => write!(f, "skipped"),
|
||||
StageOutcome::PipelineSuccess => write!(f, "pipeline-success"),
|
||||
StageOutcome::PipelineFailure => write!(f, "pipeline-failure"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about a single pipeline stage execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StageInfo {
|
||||
/// Stage name identifier.
|
||||
pub name: String,
|
||||
/// Execution phase.
|
||||
pub phase: StagePhase,
|
||||
/// Substage identifier within the phase.
|
||||
pub substage: String,
|
||||
pub result: StageResult,
|
||||
/// Stage outcome.
|
||||
pub result: StageOutcome,
|
||||
/// Execution duration in milliseconds.
|
||||
pub duration_ms: u64,
|
||||
/// Stage start timestamp.
|
||||
pub started_at: DateTime<Utc>,
|
||||
/// Stage completion timestamp.
|
||||
pub finished_at: DateTime<Utc>,
|
||||
/// Error message if the stage failed.
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl StageInfo {
|
||||
/// Returns the full stage name in `phase.substage` format.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// # use workshop_baker::types::buildstatus::{StageInfo, StagePhase, StageResult};
|
||||
/// # use chrono::Utc;
|
||||
/// let info = StageInfo {
|
||||
/// name: "bootstrap".to_string(),
|
||||
/// phase: StagePhase::Prebake,
|
||||
/// substage: "bootstrap".to_string(),
|
||||
/// result: StageResult::Success,
|
||||
/// duration_ms: 1000,
|
||||
/// started_at: Utc::now(),
|
||||
/// finished_at: Utc::now(),
|
||||
/// error_message: None,
|
||||
/// };
|
||||
/// assert_eq!(info.full_name(), "prebake.bootstrap");
|
||||
/// ```
|
||||
pub fn full_name(&self) -> String {
|
||||
format!("{}.{}", self.phase, self.substage)
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregate build status containing all stage information.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct BuildStatus {
|
||||
/// List of stage execution records.
|
||||
pub stages: Vec<StageInfo>,
|
||||
}
|
||||
|
||||
impl BuildStatus {
|
||||
pub fn final_result(&self) -> StageResult {
|
||||
/// Returns the result of the final stage, or `Success` if no stages recorded.
|
||||
pub fn final_result(&self) -> StageOutcome {
|
||||
self.stages
|
||||
.last()
|
||||
.map(|s| s.result.clone())
|
||||
.unwrap_or(StageResult::Success)
|
||||
.unwrap_or(StageOutcome::Success)
|
||||
}
|
||||
|
||||
/// Returns `true` if any stage failed.
|
||||
pub fn has_failures(&self) -> bool {
|
||||
self.stages
|
||||
.iter()
|
||||
.any(|s| matches!(s.result, StageResult::Failure))
|
||||
.any(|s| matches!(s.result, StageOutcome::Failure))
|
||||
}
|
||||
|
||||
/// Returns the full name of the last stage, or `None` if no stages recorded.
|
||||
pub fn last_stage_name(&self) -> Option<String> {
|
||||
self.stages.last().map(|s| s.full_name())
|
||||
}
|
||||
}
|
||||
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn write_stage_status<P: AsRef<Path>>(status_dir: P, stage: StageInfo) -> io::Result<()> {
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
|
||||
let status_file = status_dir
|
||||
.as_ref()
|
||||
.join(crate::constant::STANDALONE_STATUS_FILE);
|
||||
let line = serde_json::to_string(&stage).map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("JSON serialization error: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&status_file)?;
|
||||
|
||||
writeln!(file, "{}", line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_build_status<P: AsRef<Path>>(status_dir: P) -> io::Result<BuildStatus> {
|
||||
use std::fs;
|
||||
|
||||
let status_file = status_dir
|
||||
.as_ref()
|
||||
.join(crate::constant::STANDALONE_STATUS_FILE);
|
||||
|
||||
if !status_file.exists() {
|
||||
return Ok(BuildStatus::default());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&status_file)?;
|
||||
let stages: Vec<StageInfo> = content
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| {
|
||||
serde_json::from_str(line).map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("JSON deserialization error: {}", e),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(BuildStatus { stages })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn create_test_stage(name: &str, phase: StagePhase, result: StageResult) -> StageInfo {
|
||||
fn create_test_stage(name: &str, phase: StagePhase, result: StageOutcome) -> StageInfo {
|
||||
let now = Utc::now();
|
||||
StageInfo {
|
||||
name: name.to_string(),
|
||||
@@ -152,37 +154,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_and_read_stage_status() {
|
||||
let dir = tempdir().unwrap();
|
||||
let stage = create_test_stage("bootstrap", StagePhase::Prebake, StageResult::Success);
|
||||
|
||||
write_stage_status(&dir, stage.clone()).unwrap();
|
||||
|
||||
let status = read_build_status(&dir).unwrap();
|
||||
assert_eq!(status.stages.len(), 1);
|
||||
assert_eq!(status.stages[0].substage, "bootstrap");
|
||||
assert_eq!(status.stages[0].result, StageResult::Success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_status_final_result() {
|
||||
let status = BuildStatus {
|
||||
stages: vec![
|
||||
create_test_stage("bootstrap", StagePhase::Prebake, StageResult::Success),
|
||||
create_test_stage("build", StagePhase::Bake, StageResult::Failure),
|
||||
create_test_stage("bootstrap", StagePhase::Prebake, StageOutcome::Success),
|
||||
create_test_stage("build", StagePhase::Bake, StageOutcome::Failure),
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(status.final_result(), StageResult::Failure);
|
||||
assert_eq!(status.final_result(), StageOutcome::Failure);
|
||||
assert!(status.has_failures());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_empty_status() {
|
||||
let dir = tempdir().unwrap();
|
||||
let status = read_build_status(&dir).unwrap();
|
||||
assert!(status.stages.is_empty());
|
||||
assert_eq!(status.final_result(), StageResult::Success);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct CacheDirectory {
|
||||
pub path: String,
|
||||
#[serde(default = "default_strategy")]
|
||||
pub strategy: CacheStrategyType,
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: CacheMode,
|
||||
}
|
||||
|
||||
fn default_strategy() -> CacheStrategyType {
|
||||
CacheStrategyType::Always
|
||||
}
|
||||
fn default_mode() -> CacheMode {
|
||||
CacheMode::Zstd
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CacheStrategyType {
|
||||
Always,
|
||||
Readonly,
|
||||
Clean,
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CacheMode {
|
||||
Gzip,
|
||||
Zstd,
|
||||
None,
|
||||
Dir,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct CacheStrategy {
|
||||
#[serde(default = "default_ttl_days", rename = "ttl_days")]
|
||||
pub ttl_days: u32,
|
||||
#[serde(default = "default_max_size_gb", rename = "max_size_gb")]
|
||||
pub max_size_gb: u32,
|
||||
#[serde(default = "default_cleanup_policy", rename = "cleanup_policy")]
|
||||
pub cleanup_policy: CleanupPolicy,
|
||||
}
|
||||
|
||||
fn default_ttl_days() -> u32 {
|
||||
30
|
||||
}
|
||||
fn default_max_size_gb() -> u32 {
|
||||
20
|
||||
}
|
||||
fn default_cleanup_policy() -> CleanupPolicy {
|
||||
CleanupPolicy::Lru
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CleanupPolicy {
|
||||
Lru,
|
||||
Fifo,
|
||||
Size,
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
use crate::types::time::deserialize_duration_ms;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct CustomCommand {
|
||||
#[serde(default = "default_name")]
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub environment: Option<HashMap<String, Option<String>>>,
|
||||
#[serde(default)]
|
||||
pub working_dir: Option<String>,
|
||||
#[serde(
|
||||
default = "default_timeout_ms",
|
||||
deserialize_with = "deserialize_duration_ms",
|
||||
rename = "timeout"
|
||||
)]
|
||||
pub timeout_ms: u64,
|
||||
#[serde(default)]
|
||||
pub argument: serde_yaml::Value,
|
||||
}
|
||||
|
||||
fn default_timeout_ms() -> u64 {
|
||||
300_000
|
||||
}
|
||||
|
||||
fn default_name() -> String {
|
||||
"(unnamed)".to_string()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user