Compare commits

...

30 Commits

Author SHA1 Message Date
Catty Steve 117660d5c4 feat(baker/finalize): notification system and consolidate status types
- Rename types/build_status.rs to types/buildstatus.rs
- Rename StageInfo.sub_stage field to substage for consistency
- Move notification logic from finalize to dedicated notify module
- Remove notification module from finalize (moved to notify)
- Add notify module with event queuing, retry, and priority scheduling
- Add exponential backoff for temporary notification failures
- Implement plugin filtering by name list during registration
- Add fetch_plugins helper for batch plugin download
- Update finalize plugin registration to accept optional plugin filter
- Fix finalize config template indentation for notification policy
- Move all YAML templates to templates/ directory
2026-04-23 16:18:20 +08:00
Catty Steve 07d76d612d refactor!: move notification module to src/notify.rs
BREAKING CHANGE: notification system is now separate module
2026-04-22 21:21:26 +08:00
Catty Steve 2658d0ebfa feat(baker): add build status tracking and notification dispatch system
Add new `build_status` types module for tracking pipeline stage
execution
status across prebake, bake, and finalize phases. Introduce
`NotificationCore`
dispatcher that processes notification methods by priority groups with
fallback
on failure. Status files written to `/tmp/.hbwstatus` in JSON lines
format.
2026-04-22 18:45:22 +08:00
Catty Steve 40f307b471 chore: remove unused crates and add documentation
- Delete workshop-agent (HTTP server stub)
- Delete workshop-builder-native (empty stub)
- Delete workshop-helper-mac (unused)
- Delete workshop-llm-detector (moved to separate repo)
- Add AGENTS.md to bake/, engine/, finalize/, prebake/, cert/, vault/
- Fix finalize plugin validation and logging improvements
2026-04-22 12:47:48 +08:00
Catty Steve 7b3b71a4b3 feat(finalize): support multiple manifest extensions and add hook tests
- Remove underscore suffix from FINALIZE_SHELL_ENVPREFIX constant
- Split FINALIZE_PLUGIN_MANIFEST into name + extensions for flexible
  manifest discovery
- Add ManifestNotFound error variant for clearer plugin loading errors
- Add debug logging for plugin download operations
- Add integration tests for finalize hook execution
- Remove obsolete clarification.md
2026-04-21 21:40:19 +08:00
Catty Steve 0dd4a157ef feat(finalize): add standalone mode with automatic plugin fetching and
hook execution

- Add FinalizeHooks config for early/late stage hook execution
- Support plugin: prefixed commands in hooks to invoke registered
  plugins
- Implement standalone mode that auto-downloads plugins before
  registration
- Add shallow clone and checksum options to plugin config
- Normalize retention format to lowercase in template
- Refactor PathBuf to Path in plugin fetch modules for consistency
2026-04-21 18:13:24 +08:00
Catty Steve d8ba166a3b refactor: centralize ExecutionContext and event handling in main
- Lift ctx creation and event_receiver spawning to main
- Inject ctx and event_tx into prebake/bake/finalize
- Add standalone flag to ExecutionContext
- Wire up finalize command in main
2026-04-21 11:38:02 +08:00
Catty Steve e9128adfa0 feat(baker/finalize): implement plugin system with fetch and mail
support

- Add finalize plugin system with fetch logic for git/https/file sources
- Add plugin checksum verification (SHA256/SHA512, reject MD5/SHA1)
- Add tar archive extraction with gzip/zstd support
- Add git clone and checkout with tag/commit fallback
- Add internal mail plugin with SMTP support and template rendering
- Add shell plugin with YAML-to-envvar argument conversion
- Add retry and timeout configuration for plugins
- Add duration deserialization to milliseconds with human-readable
  formats
- Replace timeout fields with timeout_ms throughout codebase
- Update CustomCommand timeout_ms and hook timeout handling
- Add HBW_ARG_ prefix for shell plugin environment variables
2026-04-21 10:33:21 +08:00
Catty Steve 0c089fd634 feat(baker/finalize): add YAML to shell env var conversion 2026-04-19 09:40:05 +08:00
Catty Steve a1da093176 feat(baker/finalize): Implement plugin manifest support and typed
metadata

- Rename env vars WS_* to HBW_* for consistency
- Add PluginMetadata struct and PluginType enum for typed plugin info
- Support manifest.yml files for external plugin discovery
- Add shell/rhai/dylib plugin stubs with proper type signatures
- Implement shallow argument merge for plugin runtime arguments
2026-04-18 23:58:33 +08:00
Catty Steve a8ed935f6a feat(baker/finalize): Add finalize stage framework with plugin system
and template

Implements the finalize stage infrastructure for HoneyBiscuitWorkshop:
- New finalize module with config, error, event, plugin, stage, and
  template submodules
- Plugin system supporting internal (webhook, mail, satori, in-site) and
  external plugins
- FinalizeConfig schema with version validation for finalize.yml
- Add glob, duration-str, lettre dependencies to workshop-baker
- Expand project documentation (clarification.md, description.md)
- Update .gitignore and fix bake.rs pipeline_functions access

BREAKING CHANGE: Not a buildable version!
2026-04-16 23:33:03 +08:00
Catty Steve 73e1238e20 refactor(bake): redesign parser with state machine and improve builder
- Replace flat parser with state machine (Normal/Decorator/InFunction)
- Change parse_script to accept reference and add proper error handling
- Add Function::find_decorator helper method
- Refactor build_script to accept &Function and support @if/@export
  decorators
- Add condition code generation for @if decorator
- Add export/preexport code for environment variable capture
- Handle trivial scripts by creating Function directly
- Add constants: PIPELINE_SKIP_ERRORCODE and TRIVIAL_SCRIPT_NAME
- Temporarily disable order-based sorting assertion (see TODO)
- Add empty finalize.rs module skeleton
- Update integration tests to expect errors appropriately
2026-04-06 00:07:02 +08:00
Catty Steve 246c2b22f1 feat(bake): Add streaming execution output and Repology endpoint config
- Implement async stdout/stderr streaming with event channel
- Add Repology endpoint configuration in dependencies config
- Refactor PackageManager trait to return Vec<Command>
- Add bootstrap configuration example
- Remove temp/privdrop.rs
2026-04-04 16:59:39 +08:00
Catty Steve 418bdfb2f0 refactor!: remove workshop-pipeline crate and add repology support
BREAKING CHANGE: workshop-pipeline crate removed entirely

- Remove workshop-pipeline/ (config structures, template resolution)
- Add Repology package name resolution to workshop-baker
- Add UPM system dependency collection for cross-pm resolution
- Replace AGENTS.md with concise knowledge base format
- Add crate-specific AGENTS.md files
- Fix various clippy warnings and derive Default where applicable
2026-04-04 13:24:26 +08:00
Catty Steve 2133f8b9b3 feat(bake): implement full bake functionality with template-based script
generation and pipeline scheduling

- Add benchmark infrastructure (Criterion) for parser performance
  testing
- Enhance decorator parsing with new types: Pipe, Parallel, Daemon,
  Health, and Unknown
- Improve parser to track line numbers and handle nested braces
  correctly
- Add implicit `@after` dependencies for pipeline functions in
  topological sort
- Introduce `use_template` flag in builder to support trivial script
  execution
- Update bake function signature to accept Cli parameters and privilege
  detection
- Refactor error handling with structured `ScriptGenerationFailed`
  variant
- Extend integration tests for privilege dropping, engine execution, and
  bake phase
- Comment out daemon and apt modules temporarily for refactoring
2026-04-04 10:12:57 +08:00
Catty Steve b59a702925 feat(bake): add bake module for script execution and refactor error
handling

- Add new bake module with parser, scheduler, decorator, and builder
- Implement topological sorting for functions with @after dependencies
- Add error handling for circular dependencies and unknown dependencies
- Introduce constants module for shared path definitions
- Add BakeError type (renamed from BuilderError) with new variants
- Add PrebakeStage::Never variant for "never drop privileges"
- Simplify decorator parsing and clean up comments
- Remove RustyVault and rust-tongsuo submodules as they are unused
  (temporarily)
- Update .gitignore to include .ruff_cache
2026-04-02 19:34:21 +08:00
Catty Steve b02407a02d chore(baker/prebake): Add comprehensive documentation to prebake module
- Document prebake.rs, bootstrap.rs, and hook.rs with module-level and
  function-level docs
- Rename parse_prebake_config to parse for conciseness
- Change bootstrap() return type from ExecutionResult to ()
- Add dry-run support to bootstrap stage
- Improve test robustness with Docker availability check and unique
  container names
2026-04-01 10:54:51 +08:00
Catty Steve 15aaa6f310 feat(baker/prebake): Add configurable bootstrap stage with dynamic
script generation

The bootstrap stage now reads configuration from prebake.yml to
dynamically generate bootstrap scripts for user creation, workspace
setup, and privilege executor configuration.
Adds the `which` crate for binary detection.
2026-03-31 23:46:11 +08:00
Catty Steve 9174c4dd2a feat: rename to workshop-baker and implement stage-based prebake
workflow

- Rename crate/library/binary from workshop-executor to workshop-baker
- Remove unused modules (decorator, parser, schedule, variable) from lib
- Implement stage-based privilege dropping with prebake_drop_privilege()
- Add dry-run support in executor and CLI
- Add bootstrap.sh template script for user/workspace setup
- Add PrebakeStage::from_str() and Default impl
- Add get_drop_after() function for security config
- Fix version matching to handle "1.0" format variants
- Fix UPM all_managers() to return custom::Custom
- Add Python integration tests with Docker
- Simplify examples/prebake.yml to match template
2026-03-31 20:54:48 +08:00
Catty Steve 20919d53ad refactor(baker/bake): move relating part into separate directory 2026-03-31 13:16:14 +08:00
Catty Steve 3e8b256889 feat(baker/engine): add depsuser stage 2026-03-31 11:53:55 +08:00
Catty Steve 0f6aed6f94 feat(engine): implement UPM/UserPackageManager interface 2026-03-31 11:39:47 +08:00
Catty Steve 960f03269c feat: rename workshop-executor to workshop-baker and add comprehensive
documentation

- Rename crate from workshop-executor to workshop-baker
- Add MDBook documentation infrastructure with Chinese docs (Vol1/2/3)
- Implement engine module with cgroups resource management
- Add package manager support (apt, pacman)
- Add daemon module with Unix socket IPC and heartbeat
- Add privdrop dependency for privilege dropping
- Implement prebake stages (bootstrap, depssystem, depsuser,
  environment, hooks)
- Add new bake.sh.tmpl pipeline template for osu! project
- Add pipeline implicit parameter documentation (PIPELINE_BAREMETAL_*,
  PIPELINE_CONTAINER_*, PIPELINE_UNCOVER_SECRET)
2026-03-30 20:24:54 +08:00
Catty Steve 69056a3a4a feat: add Package Manager 2026-03-11 14:49:10 +08:00
Catty Steve c80500f6ac feat: implement docker env creation for prebake
Also we introduced opencode for code production.
2026-03-10 11:59:58 +08:00
Catty Steve d7ba65fbf4 feat: finish monitor in executor, daemonize executor
Signed-off-by: Catty Steve <4795515+Catty2014@user.noreply.gitee.com>
2026-01-15 11:54:42 +08:00
Catty Steve ab7cbfd730 feat: add engine to ws-executor
Signed-off-by: Catty Steve <4795515+Catty2014@user.noreply.gitee.com>
To be done.
2025-12-28 23:05:02 +08:00
Catty Steve ecf3c66905 feat: add workshop-{executor, helper-mac}
Signed-off-by: Catty Steve <4795515+Catty2014@user.noreply.gitee.com>
2025-12-27 21:46:16 +08:00
Catty Steve f7ea4dfb3e feat: add workshop-pipeline
Signed-off-by: Catty Steve <4795515+Catty2014@user.noreply.gitee.com>
Workshop-pipeline is a crate for parsing prebake/bake/finalize config.
Not finished yet, further testing required.
2025-11-14 11:05:05 +08:00
Catty Steve f66a36d0f4 feat: add workshop-agent and workshop-cert
Signed-off-by: Catty Steve <4795515+Catty2014@user.noreply.gitee.com>
2025-11-13 23:49:16 +08:00
141 changed files with 20782 additions and 2595 deletions
+9
View File
@@ -1,2 +1,11 @@
target target
playground playground
*.old
.ruff_cache
.codeartsdoer
archived
.sisyphus
*.bak
*.clean
session*
+107
View File
@@ -0,0 +1,107 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2026-04-22
**Commit:** 7b3b71a
**Branch:** master
## OVERVIEW
HoneyBiscuitWorkshop is a Rust-based CI/CD system ("蜜饼工坊") with LLM-powered pipeline auto-configuration. 9 independent Cargo crates, no workspace.
## STRUCTURE
```
./
├── workshop-baker/ # Main orchestrator (CLI + daemon)
├── workshop-agent/ # HTTP agent server
├── workshop-pipeline/ # Config library (prebake/finalize)
├── workshop-llm-detector/ # LLM repo analyzer
├── workshop-vault/ # Secret management client
├── workshop-cert/ # TLS certificate generator
├── workshop-deviceid/ # Device ID library
├── workshop-builder-native/ # Stub (unused)
├── workshop-helper-mac/ # MAC address utility
├── docs/ # mdBook documentation (zh-CN)
├── playground/ # Experimentation
└── third_party/ # Empty
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Baker (main) | `workshop-baker/src/{bake,prebake,engine}/` | Core pipeline logic |
| Agent server | `workshop-agent/src/` | Actix-web HTTP + TLS |
| Pipeline config | `workshop-pipeline/src/config/` | prebake/finalize YAML |
| LLM integration | `workshop-llm-detector/src/llm/` | Ollama/OpenAI clients |
| Secrets | `workshop-vault/src/` | Vault client wrapper |
## CODE MAP
| Symbol | Type | Location | Role |
|--------|------|----------|------|
| PipelineConfig | struct | workshop-pipeline/src/lib.rs:27 | Full pipeline config |
| CICDLLMHelper | struct | workshop-llm-detector/src/lib.rs:16 | LLM analysis entry |
| Engine | struct | workshop-baker/src/engine.rs | Build executor |
| VaultClient | struct | workshop-vault/src/client.rs | Secret ops |
## CONVENTIONS
### Rust
- **Edition**: 2024 (non-standard, requires Rust 1.85+)
- **Naming**: `snake_case` files/modules, `PascalCase` types, `SCREAMING_SNAKE_CASE` consts
- **Error handling**: `thiserror` + `anyhow` combo
- **Async**: `#[tokio::main]` + `tokio` with "full" features
- **Imports**: `std``external_crate``crate::module`
### Testing
- **Rust**: `#[test]` inline, `#[tokio::test]` async, `tests/*.rs` integration
- **Python**: `pytest` with `tests/integration/test_*.py` (Docker-based)
- **Benchmarks**: Criterion with `harness = false`
### Build Pipeline
- **prebake.yml**: Environment setup (docker/firecracker/baremetal)
- **bake.sh**: Build execution with `# @pipeline` decorators
- **finalize.yml**: Packaging, deployment, notifications
- **Annotations**: `@timeout()`, `@retry()`, `@parallel`, `@fallible`
## ANTI-PATTERNS (THIS PROJECT)
1. **Python in Rust project** — workshop-baker has Python tests (pytest.ini, test_prebake.py)
2. **temp/ directory** — Non-standard in Rust projects
3. **Certificates in source** — artifact workshop-cert/certs/ not excluded
4. **Deprecated projects** — workshop-executor.old, workshop-monitor.old still present
5. **Hardcoded mirrors** — Tsinghua University mirrors in configs
6. **Rust 2024 edition** — May not work with stable toolchains
## UNIQUE STYLES
- **Pipeline DSL**: Shell scripts with `# @decorator` annotations
- **Dual-mode baker**: CLI commands + daemon mode
- **LLM cookbook system**: YAML cookbooks for language-specific builds
- **Chinese docs** — Documentation primarily in zh-CN
- **User "vulcan"** — Custom build user (not root/runner)
## COMMANDS
```bash
# Build
cargo build --release -p workshop-baker
# Test
cargo test -p workshop-baker
pytest tests/integration/test_prebake.py -v
# Lint
cargo clippy -- -D warnings
cargo fmt --all
# Run baker
cargo run --bin workshop-baker -- prebake config.yml
cargo run --bin workshop-baker -- bake script.sh
# Run agent
cargo run --bin workshop-agent
```
## NOTES
- **Docker required** for integration tests
- **workshop-builder-native** is a stub (3-line Hello World)
- No root workspace Cargo.toml — each crate is independent
- `AGENTS.md.old` at root — legacy documentation
Submodule RustyVault deleted from 4efe033ce7
+62
View File
@@ -0,0 +1,62 @@
# HBW(HoneyBiscuitWorkshop) 项目规划与介绍
本项目为一个基于Rust的CI/CD系统,名为“蜜饼工坊”(HoneyBiscuitWorkshop)。
该名称取材于明日方舟世界观中小刻爱吃的蜜饼和她的管理者火神大姐的工坊。
## 目标愿景
这个CI/CD的最终用途如下:
0. 首先是一个合格的CI/CD。其参考了concourseconcourse也是本项目的起始点与初衷,见Vol3/Story.md),意图实现其大部分功能。
1. 软件包自动化
接入AOSC的autobuild+abbs树,或者Arch Linux的ABS,实现软件包的自动构建与更新
在Web/CLI(./workshop)中允许使用流水线模板快捷推送任务。示例:
./workshop push aosc:linux-kernel-6.19
./workshop push arch:python-pytorch-rocm
2. 机器学习支持,支持CUDA/ROCm/Others方案运行机器学习/深度学习/大模型训练程序
3. 对于部分工业软件/行业软件的支持,例如允许通过Vivado实现硬件设计远程综合,或者通过Blender渲染一个项目
一定程度是2的延伸
## 特性设计
1. LLM Skill/MCP 适配
允许通过Skill调用API/CLI(prefer!)与服务器通信,实现流水线自愈,流水线配置快速生成等
2. 流水线进度感知
具体说来程序可以通过读取的流水线日志来判断进行的进度,以估计剩余时间,或给出超时警告
实现上尽可能避开大模型,使用纯粹机器学习/深度学习方法,TTR=1s,单流水线持久化数据不大于16KB
3. 与koishi(Koishi.js,一个聊天机器人管理项目,https://koishi.chat/zh-CN/)等结合,实现消息推送与流水线简单管理(如快捷重启,定时启动等)
4. 基于cgroups/JobObject(计划中)的任务资源管理与计数
资源管理可以对任务资源进行限制
资源计数可以对不同权限的账号计算配额
5. prebake.yml, bake.sh, finalize.yml的最简流水线配置
prebake.yml的模板于本目录给出,尽可能完善
prebake为纯粹声明式配置,具有固定的用户自定义锚点,具有权限配置
bake.sh的模板于本目录给出,仅供参考
bake使用@decorator(私有)装饰器语法增强语义,主要是命令式,兼容纯shell
finalize.yml的模板正在设计,计划上支持shell/rhai/dylib(待Rust ABI稳定)
6. 静态二进制发布,支持各种架构,系统与C库
我们尽可能为Rust Tier1架构做完整支持(Windows暂缓)
会尤其注重新兴架构: RISC-V和LoongArch的支持
理论上不支持32位,但不会限制
!!!不支持没有操作系统的裸机(nostd)!!!
7. 构建机支持裸机,容器,FireCracker虚拟机与自定义环境
8. 可配置的构建缓存与环境缓存
9. 本地流水线快捷验证
可以在不借助服务器的情况下快速验证流水线语法的正确性,在借助本机容器/虚拟机的情况下进行试构建
10. 本地文件夹推送构建
可以推送本地的任何一个文件夹到服务器,其会根据.workshop中的规则文件/LLM自生成进行构建,支持基于哈希+分块的增量传输
11. Vol1/2/3 用户/管理员/开发者文档
CI/CD采用Web+CLI+Server+Multi (Agent+Baker.bin(Bakerd+Baker))设计。
12. 最小化外部依赖
可能且推荐的外部扩展包括:
- RustyVault/Vault: 凭据存储
- Koishi: IM集成与通知
- Repology: 跨发行版包名映射
可能的外部扩展包括:
- SQL DB
- NoSQL DB
- MQ
13. 分发
事实上单二进制是与有系统依赖相对的。分发时,server/agent/baker需要动态下载。
除去官方分发(软件包或者统一软件包(如flatpak))外,通过curl+sh下载验证并安装server。
server自动下载并验证正确的agent与bakeragent/baker可缓存。
提供agent.tar与baker.tar便于离线部署
+1
View File
@@ -0,0 +1 @@
book
+5
View File
@@ -0,0 +1,5 @@
[book]
title = "HoneyBiscuitWorkshop Documentation"
authors = ["Catty Steve"]
language = "zh-CN"
+11
View File
@@ -0,0 +1,11 @@
# HoneyBiscuitWorkshop Documentation
- [中文](zh-CN/index.md)
- [卷1 用户手册](zh-CN/vol1_user/index.md)
- [卷2 管理员手册](zh-CN/vol2_admin/index.md)
- [流水线隐参数](zh-CN/vol2_admin/pipeline_implicit_parameter.md)
- [PIPELINE_UNCOVER_SECRET](zh-CN/vol2_admin/pipeline_uncover_secret.md)
- [PIPELINE_BAREMETAL_PKGMAN](zh-CN/vol2_admin/pipeline_baremetal_pkgman.md)
- [PIPELINE_BAREMETAL_ELEVATE](zh-CN/vol2_admin/pipeline_baremetal_elevate.md)
- [PIPELINE_CONTAINER_PRIVILEGED](zh-CN/vol2_admin/pipeline_container_privileged.md)
- [卷3 开发者手册](zh-CN/vol3_dev/index.md)
View File
+1
View File
@@ -0,0 +1 @@
some
@@ -0,0 +1,44 @@
# PIPELINE_<PERMISSION_NAME>: 名称的字面意思描述
[一句话简介]
0. 预警/FOREWARN
[警告读者可能的风险]
1. 概要/Synopsis
[给出其定义与作用]
2. 场景/Scenario
[给出其常见配置场景]
3. 描述/Description
[给出其细节描述]
4. 授权/Authorize
[给出权限的获得方法]
5. 撤回/Revoke
[给出权限的撤回方法]
6. 生命周期/Lifecycle
[给出权限的生命周期描述]
7. 审计/Audit
[描述其对审计系统的影响,若无请忽略]
8. 参数/Options
[给出可配置的参数,若无请忽略]
9. 建议/Suggestions
[字面意思]
10. 常见问题/FAQ
[给出用户提出的问题。此时不应该填充任何内容。]
11~90. 自定义字段
98. 作者/Author
[字面意思,LLM的使用应该也在这里标注]
99. 参见/See Also
[给出可能有关的页面]
+1
View File
@@ -0,0 +1 @@
meow?
@@ -0,0 +1,184 @@
# PIPELINE_BAREMETAL_ELEVATE:裸机环境完整提权
> [!WARNING]
> 此权限允许裸机流水线以完整 root 权限运行,可执行任意系统级操作。
> 滥用可能导致系统崩溃、数据永久丢失、安全入侵或合规违规。
> **仅在绝对必要时使用,必须经过严格审批、2FA 验证并记录完整审计。**
## 1. 概要
PIPELINE_BAREMETAL_ELEVATE 是一个流水线隐参数,用于**在裸机环境下临时授予流水线完整的 root 权限**。当该参数生效时,流水线可以执行任何系统级操作,包括修改系统配置、安装软件、操作内核模块等。
**此参数默认不开启,需要管理员通过严格流程审批并授权。**
## 2. 场景
| 适用场景 | 说明 |
|----------|------|
| 内核模块编译与加载 | 需要安装 kernel headers 并动态加载模块 |
| 系统级性能调优 | 修改 sysctl 参数、CPU 调频策略、IRQ 亲和性 |
| 硬件设备配置 | 配置 PCIe 直通、GPU 驱动安装、FPGA 编程 |
| 系统镜像定制 | 修改根文件系统、引导配置 |
| 遗留软件兼容 | 某些老旧软件硬编码需要 root 运行 |
| 不适用场景 | 说明 |
|------------|------|
| 普通软件包安装 | 请用 PIPELINE_BAREMETAL_PKGMAN |
| 语言级依赖 | 请用 DepsUser 阶段的包管理器 |
| 容器化构建 | 不需要裸机提权 |
| 日常构建 | 正常流水线不应使用 root |
## 3. 描述
### 技术本质
PIPELINE_BAREMETAL_ELEVATE 通过临时切换进程凭据实现完整提权:
1. 在 DepsSystem 阶段开始前,baker 进程的 uid 临时切换为 0 (root)
2. 所有子进程继承 root 权限
3. 可执行任何系统调用、访问任何文件、修改任何配置
4. DepsSystem 阶段结束后自动降权回普通用户
5. 提权期间的所有操作都被强制审计
### 边界
| 允许的操作 | 不允许的操作 |
|------------|--------------|
| 安装/卸载系统软件包 | 修改系统引导项(超出流水线生命周期) |
| 修改系统配置文件 | 永久关闭 SELinux/AppArmor |
| 加载/卸载内核模块 | 安装持久化后门或定时任务 |
| 操作其他用户的文件 | 删除其他用户的流水线数据 |
| 修改系统服务状态 | 修改审计系统配置 |
| 访问硬件设备 | 覆盖系统关键二进制 |
### 与其他参数的关系
| 参数 | 关系 |
|------|------|
| PIPELINE_BAREMETAL_PKGMAN | ELEVATE 包含 PKGMAN 的所有能力,两者互斥使用 |
| PIPELINE_UNCOVER_SECRET | 可同时使用,但风险叠加,需特别审批 |
| PIPELINE_CONTAINER_PRIVILEGED | 适用于不同环境,无直接关联 |
## 4. 授权
| 方式 | 适用场景 | 操作示例 |
|------|----------|----------|
| 管理员 CLI + 2FA | 紧急生产问题 | |
| 审批系统集成 | 企业级合规流程 | |
| 双人授权 | 高安全环境 | |
**授权流程**
1. 用户或管理员发起授权请求,必须填写详细理由
2. 系统要求发起人完成 2FA 验证
3. 需要至少一名其他管理员二次审批(可选,可配置)
4. 授权确认后,系统生成短期令牌随流水线下发
**授权确认示例**
```
🚨 高危操作授权请求 🚨
正在申请 PIPELINE_BAREMETAL_ELEVATE 权限
流水线: pipeline-12345
申请人: user@example.com
理由: 需要安装自定义内核模块并调优 NUMA 参数
风险确认:
- 此权限允许完整 root 访问
- 可能造成系统不稳定或数据丢失
- 所有操作将被强制审计
2FA 验证: ******
请输入 "I UNDERSTAND THE RISKS" 确认:
>
```
## 5. 撤回
| 撤回方式 | 说明 |
|----------|------|
| 手动撤回 | |
| 自动失效 | DepsSystem 阶段结束后自动降权 |
| 流水线结束 | 流水线结束时强制回收权限 |
| 强制回收 | 审计系统检测到异常行为时可立即终止流水线 |
| 超时回收 | 超过配置的最大提权时长后自动降权 |
## 6. 生命周期
| 状态 | 说明 |
|------|------|
| PENDING | 等待审批 |
| APPROVED | 已审批通过 |
| ACTIVE | 提权生效中 |
| EXPIRED | 超过最大时长或阶段结束 |
| REVOKED | 被管理员强制撤回 |
| AUDITED | 已完成审计归档 |
## 7. 审计
所有提权期间的操作都会被强制记录,且日志不可篡改:
| 字段 | 说明 |
|------|------|
| `event` | GRANTED / COMMAND_EXECUTED / FILE_ACCESS / EXPIRE |
| `user` | 申请人 |
| `approvers` | 审批人列表 |
| `pipeline_id` | 关联流水线 ID |
| `timestamp` | 操作时间 |
| `command` | 执行的完整命令行 |
| `working_dir` | 执行路径 |
| `exit_code` | 命令执行结果 |
| `accessed_files` | 访问的关键文件列表 |
| `syscalls` | 关键系统调用记录 |
**日志示例**
```json
{
"timestamp": "",
"event": "",
"pipeline_id": "",
"user": "",
"command": ""
}
```
## 8. 参数
### 全局配置
```toml
[security.baremetal_elevate]
enabled = false # 默认关闭
require_2fa = true # 必须 2FA
require_dual_approval = true # 需要双人审批
max_duration = "30m" # 最长提权时间
allowed_stages = ["DepsSystem"] # 允许提权的阶段
audit_log = true # 强制审计
notify_channels = ["email", "slack"] # 通知渠道
```
### 运行时参数
## 9. 建议
### 对用户
### 对管理员
## 10. 常见问题
## 98. 作者
## 99. 参见
@@ -0,0 +1,161 @@
# PIPELINE_BAREMETAL_PKGMAN:裸机环境包管理器权限
> [!WARNING]
> 此权限允许裸机流水线在 DepsSystem 阶段临时使用系统包管理器(需要 root 权限的操作)。
> 滥用可能导致系统环境被破坏或意外安装恶意软件包。
> **非必要不使用,使用时必须经过管理员审批并记录审计。**
## 1. 概要
PIPELINE_BAREMETAL_PKGMAN 是一个流水线隐参数,用于**在裸机环境下临时授予 DepsSystem 阶段使用系统包管理器的权限**。当该参数生效时,流水线可以执行 apt-get、pacman、yum 等包管理器命令安装构建依赖,但**不授予完整的 root 权限**。
**此参数默认不开启,需要管理员显式授予。**
## 2. 场景
| 适用场景 | 说明 |
|----------|------|
| 系统依赖安装 | 构建过程需要 gcc、make、openssl-dev 等系统包 |
| 基础环境准备 | 裸机环境缺少必要的构建工具链 |
| 内核模块编译 | 需要安装 kernel headers 等特权操作 |
| 性能调优工具 | 某些性能分析工具需要系统级安装 |
| 不适用场景 | 说明 |
|------------|------|
| 任意命令执行 | 请用 PIPELINE_BAREMETAL_ELEVATE |
| 容器化构建 | 不需要裸机包管理器 |
| 语言级依赖 | 请用 DepsUser 阶段的 cargo/pip/npm |
## 3. 描述
### 技术本质
PIPELINE_BAREMETAL_PKGMAN 通过 sudoers 白名单实现受限提权:
1. 系统在 DepsSystem 阶段开始前,为 baker 配置临时 sudo 规则
2. 仅允许执行预设的包管理器命令列表
3. 所有命令执行都被审计记录
4. DepsSystem 阶段结束后自动移除 sudo 权限
### 边界
| 允许的操作 | 不允许的操作 |
|------------|--------------|
| apt-get install | 任意 shell 命令 |
| pacman -S | 修改系统配置文件 |
| yum install | 操作其他用户的文件 |
| dnf install | 加载内核模块 |
| zypper install | 安装持久化服务 |
| 包管理器缓存更新 | 修改系统引导项 |
### 依赖关系
- 需要系统启用 `security.baremetal_pkgman.enabled`(默认开启)
- 与其他隐参数(如 UNCOVER_SECRET)可同时使用,但需评估叠加风险
- 与 BAREMETAL_ELEVATE 互斥(后者已包含包管理器权限)
## 4. 授权
| 方式 | 适用场景 | 操作示例 |
|------|----------|----------|
| 管理员 CLI | 临时授权 | |
| 审批系统集成 | 企业流程 | |
| 用户申请 | 常规需求 | |
**授权流程**
1. 管理员或用户发起授权请求
2. 系统验证请求者权限
3. 授权确认后,参数随流水线下发
**授权确认示例**
```
⚠️ 正在授予 PIPELINE_BAREMETAL_PKGMAN 权限
流水线: pipeline-12345
申请人: user@example.com
理由: 需要安装 gcc、make 等构建依赖
此权限允许:
- 在 DepsSystem 阶段使用包管理器
- 仅限预设命令列表
- 不授予其他 root 权限
确认授予?(y/N)
```
## 5. 撤回
| 撤回方式 | 说明 |
|----------|------|
| 手动撤回 | |
| 自动失效 | DepsSystem 阶段结束后自动回收 |
| 流水线结束 | 流水线结束时权限自动回收 |
| 强制回收 | 审计系统检测到异常时可终止流水线 |
## 6. 生命周期
| 状态 | 说明 |
|------|------|
| PENDING | 等待审批 |
| ACTIVE | 已授权,DepsSystem 阶段可用 |
| EXPIRED | 阶段结束或流水线结束 |
| REVOKED | 被管理员强制撤回 |
## 7. 审计
| 字段 | 说明 |
|------|------|
| `event` | GRANTED / COMMAND_EXECUTED / EXPIRED / REVOKED |
| `user` | 申请人 |
| `pipeline_id` | 关联流水线 ID |
| `timestamp` | 操作时间 |
| `command` | 执行的包管理器命令 |
| `exit_code` | 命令执行结果 |
**日志示例**
```json
{
"timestamp": "",
"event": "",
"pipeline_id": "",
"user": ""
}
```
## 8. 参数
### 全局配置
```toml
[security.baremetal_pkgman]
enabled = true
require_approval = true
default_timeout = "30m"
allowed_commands = ["apt-get", "pacman", "yum", "dnf", "zypper"]
audit_log = true
```
### 运行时参数
## 9. 建议
### 对用户
### 对管理员
## 10. 常见问题
## 98. 作者
## 99. 参见
@@ -0,0 +1,187 @@
# PIPELINE_CONTAINER_PRIVILEGED:容器环境特权模式
> [!WARNING]
> 此权限允许容器以 privileged 模式运行,使容器内的进程拥有接近宿主机的权限。
> 滥用可能导致容器逃逸、宿主机被入侵或影响其他容器。
> **仅在绝对必要时使用,必须经过严格审批并记录完整审计。**
## 1. 概要
PIPELINE_CONTAINER_PRIVILEGED 是一个流水线隐参数,用于**在容器环境下授予流水线 privileged 权限**。当该参数生效时,容器将获得所有 capabilities,并可以访问宿主机的所有设备,能够执行需要特权的操作如挂载文件系统、加载内核模块等。
**此参数默认不开启,需要管理员通过严格流程审批并授权。**
## 2. 场景
| 适用场景 | 说明 |
|----------|------|
| Docker in Docker | 需要在容器内运行 Docker 命令构建镜像 |
| FUSE 文件系统 | 需要在容器内挂载用户态文件系统 |
| 内核模块测试 | 需要在容器内加载/测试内核模块 |
| 系统调用调试 | 需要 ptrace 或其他特权系统调用 |
| 性能分析工具 | 需要访问 perf、systemtap 等性能工具 |
| 硬件设备访问 | 需要直接访问 GPU、FPGA 或其他硬件设备 |
| 不适用场景 | 说明 |
|------------|------|
| 普通软件包安装 | 请用容器镜像预装或 DepsUser 阶段 |
| 构建普通应用 | 不需要特权模式 |
| 网络抓包调试 | 请用容器网络配置而非特权模式 |
## 3. 描述
### 技术本质
PIPELINE_CONTAINER_PRIVILEGED 通过修改容器运行时配置实现提权:
1. 容器启动时添加 `--privileged` 标志
2. 容器内的 root 用户拥有宿主机 root 的完整 capabilities
3. 容器可以访问宿主机所有设备(`/dev/*`
4. 可以执行通常被容器运行时限制的操作(如 mount)
5. 权限仅在该容器生命周期内有效,容器销毁后自动回收
### 与 CAP_ADD 的区别
| 模式 | 说明 | 适用场景 |
|------|------|----------|
| PRIVILEGED | 全部 capabilities + 设备访问 | 需要完整系统级访问 |
| CAP_ADD | 添加特定 capabilities | 仅需个别特权操作 |
### 边界
| 允许的操作 | 不允许的操作 |
|------------|--------------|
| 挂载文件系统 | 修改宿主机关键配置 |
| 运行 Docker 命令 | 关闭宿主机审计系统 |
| 加载内核模块 | 删除其他容器数据 |
| 访问宿主机设备 | 修改宿主机网络配置 |
| 执行 ptrace 调试 | 安装持久化后门 |
| 配置网络命名空间 | 覆盖宿主机二进制 |
### 与其他参数的关系
| 参数 | 关系 |
|------|------|
| PIPELINE_CONTAINER_CAP_ADD | 互补关系,PRIVILEGED 是更粗粒度的权限 |
| PIPELINE_UNCOVER_SECRET | 可同时使用,需评估叠加风险 |
| PIPELINE_BAREMETAL_ELEVATE | 适用于不同环境,无直接关联 |
## 4. 授权
| 方式 | 适用场景 | 操作示例 |
|------|----------|----------|
| 管理员 CLI | 临时授权 | |
| 审批系统集成 | 企业流程 | |
| 用户申请 | 常规需求 | |
**授权流程**
1. 管理员或用户发起授权请求
2. 系统验证请求者权限并记录理由
3. 授权确认后,参数随容器启动下发给运行时
4. 容器销毁后权限自动回收
**授权确认示例**
```
⚠️ 正在授予 PIPELINE_CONTAINER_PRIVILEGED 权限
容器: pipeline-12345-container
申请人: user@example.com
理由: 需要在 CI 中构建 Docker 镜像并运行容器测试
此权限允许:
- 容器以 privileged 模式运行
- 可访问宿主机所有设备
- 可执行 mount 等特权操作
- 仅在该容器生命周期内有效
风险提示:
- 可能造成容器逃逸
- 可能影响宿主机稳定性
- 所有操作将被强制审计
确认授予?(y/N)
```
## 5. 撤回
| 撤回方式 | 说明 |
|----------|------|
| 手动撤回 | |
| 自动失效 | 容器停止时权限自动回收 |
| 流水线结束 | 流水线结束时强制销毁容器 |
| 强制回收 | 审计系统检测到异常时可终止容器 |
| 超时回收 | 超过配置的最大时长后自动终止容器 |
## 6. 生命周期
| 状态 | 说明 |
|------|------|
| PENDING | 等待审批 |
| ACTIVE | 容器以特权模式运行中 |
| EXPIRED | 容器已停止 |
| REVOKED | 被管理员强制终止 |
## 7. 审计
所有特权容器内的关键操作都会被记录:
| 字段 | 说明 |
|------|------|
| `event` | GRANTED / CONTAINER_START / CONTAINER_STOP / COMMAND_EXECUTED |
| `user` | 申请人 |
| `pipeline_id` | 关联流水线 ID |
| `container_id` | 容器 ID |
| `timestamp` | 操作时间 |
| `command` | 执行的命令 |
| `mounts` | 挂载的操作 |
| `devices_accessed` | 访问的设备 |
**日志示例**
```json
{
"timestamp": "",
"event": "",
"pipeline_id": "",
"container_id": "",
"user": ""
}
```
## 8. 参数
### 全局配置
```toml
[security.container_privileged]
enabled = false # 默认关闭
require_approval = true # 需要审批
max_duration = "60m" # 最大运行时间
allowed_images = [] # 允许特权运行的镜像白名单
audit_log = true # 强制审计
notify_on_start = true # 启动时通知管理员
```
### 运行时参数
## 9. 建议
### 对用户
### 对管理员
## 10. 常见问题
## 98. 作者
## 99. 参见
@@ -0,0 +1,12 @@
# 流水线隐参数
流水线隐参数是 server 在运行时根据权限、环境、策略动态生成的参数,
随流水线定义一起下发给执行组件。
常见隐参数:
- `PIPELINE_BAREMETAL_PKGMAN`: 允许裸机环境使用包管理器(需 root)
- `PIPELINE_CONTAINER_PRIVILEGED`: 允许容器以 privileged 模式运行
- `PIPELINE_UNCOVER_SECRET`: 允许输出 secret 明文
这些参数不在 prebake.yml 中配置,由 server 管理,
并随流水线生命周期自动生效和废弃。
@@ -0,0 +1,198 @@
# PIPELINE_UNCOVER_SECRET:临时暴露敏感信息
> [!WARNING]
> 此权限允许流水线临时输出所有 secret 的明文,包括环境变量、配置文件、构建日志中的敏感信息。
> 滥用可能导致凭据泄露、数据泄露或安全入侵。
> **非必要不使用,使用时必须经过 2FA 验证并记录审计。**
## 1. 概要
PIPELINE_UNCOVER_SECRET 是一个流水线隐参数,用于**临时解除系统对敏感信息的默认保护**。当该参数生效时,原本会被遮蔽的 secret 值将以明文形式出现在日志、标准输出和构建产物中,便于调试、审计或教学。
**此参数默认不开启,需要用户通过 2FA 显式申请并获得临时授权。**
## 2. 场景
| 适用场景 | 说明 |
|----------|------|
| 调试构建失败 | 怀疑 secret 传递错误,需要确认环境变量实际值 |
| 合规性审计 | 需要验证 secret 是否正确配置和使用 |
| 教学演示 | 展示 CI/CD 流程中 secret 的完整生命周期 |
| 遗留系统兼容 | 某些老旧脚本硬编码依赖 echo secret 的方式工作 |
| 不适用场景 | 说明 |
|------------|------|
| 日常构建 | 正常流水线不应暴露 secret |
| 生产环境调试 | 请先在预发环境复现问题 |
| 权限测试 | 请用专门的测试账号 |
## 3. 描述
### 技术本质
PIPELINE_UNCOVER_SECRET 通过以下机制实现:
1. 在流水线启动时,系统生成一个临时令牌并注入 baker 环境
2. baker 在运行期间检测到该令牌,**暂停所有输出遮蔽逻辑**
3. 所有敏感变量(包括环境变量、文件内容、构建日志)都以原始形式输出
4. 权限到期后,系统自动恢复遮蔽保护
### 边界
| 允许的操作 | 不允许的操作 |
|------------|--------------|
| 查看 secret 明文 | 将 secret 持久化到外部系统 |
| 输出 secret 到日志 | 关闭审计日志 |
| 调试脚本中的 secret 使用 | 延长权限有效期 |
### 依赖关系
- 需要用户已配置 **2FA**
- 需要系统启用 `security.uncover_secret.enabled`(默认开启)
- 与其他隐参数(如 BAREMETAL_ELEVATE)互斥使用时需注意叠加风险
## 4. 授权
| 方式 | 适用场景 | 操作示例 |
|------|----------|----------|
| 用户自申请 | 调试/审计 | `hbw pipeline run --uncover-secret` |
| 紧急授权 | 生产问题 | 需 2FA + 管理员二次确认 |
**授权流程**
1. 用户发起请求时附加 `--uncover-secret` 标志
2. 系统要求输入 2FA 验证码
3. 验证通过后显示警告并要求确认
4. 用户确认后,系统生成有效期 5 分钟的临时令牌
**授权确认示例**
```
Request for PIPELINE_UNCOVER_SECRET
2FA Code: ******
WARNING: You are about to expose ALL secrets in this pipeline.
Sensitive data may appear in logs, outputs, and artifacts.
This operation is audited. Continue? (y/N)
```
## 5. 撤回
| 撤回方式 | 说明 |
|----------|------|
| 手动撤回 | 管理员可执行 `hbw admin revoke-uncover pipeline-12345` 强制终止 |
| 自动失效 | 5 分钟有效期到达后自动恢复遮蔽 |
| 流水线结束 | 无论是否到期,流水线结束时权限自动回收 |
| 强制回收 | 审计系统检测到异常行为时可自动终止流水线并回收权限 |
## 6. 生命周期
```
申请 → 2FA验证 → 确认 → 授权 → 使用 → 失效
↑ ↑ ↑ ↑ ↑ ↑
用户 系统 用户 系统 流水线 时间/事件
```
| 状态 | 说明 |
|------|------|
| PENDING | 等待 2FA 验证 |
| ACTIVE | 已授权,secret 可明文输出 |
| EXPIRED | 超过 5 分钟有效期 |
| REVOKED | 被管理员或系统强制撤回 |
## 7. 审计
所有涉及 PIPELINE_UNCOVER_SECRET 的操作都会被详细记录:
| 字段 | 说明 |
|------|------|
| `event` | GRANTED / USED / EXPIRED / REVOKED |
| `user` | 操作人邮箱 |
| `pipeline_id` | 关联流水线 ID |
| `timestamp` | 操作时间 |
| `auth_method` | 2FA |
| `expires_at` | 授权到期时间 |
| `accessed_secrets` | 授权期间访问过的 secret 列表 |
**日志示例**
```json
{
"timestamp": "2026-03-19T10:23:45Z",
"event": "PIPELINE_UNCOVER_SECRET_GRANTED",
"user": "developer@example.com",
"pipeline_id": "pl-12345",
"auth_method": "2FA",
"expires_at": "2026-03-19T10:28:45Z"
}
{
"timestamp": "2026-03-19T10:25:12Z",
"event": "SECRET_ACCESSED",
"pipeline_id": "pl-12345",
"variable": "DOCKER_PASSWORD",
"access_type": "ENV_VAR_READ"
}
```
管理员可通过审计日志追溯:
- 谁在什么时候申请了该权限
- 授权给了哪个流水线
- 哪些 secret 在授权期间被访问
- 权限是否在有效期内
## 8. 参数
### 全局配置 (bakerd.toml)
```toml
[security.uncover_secret]
enabled = true # 是否允许使用该功能
default_timeout = "5m" # 默认授权时长
require_2fa = true # 是否强制 2FA
audit_log = true # 是否记录审计日志
max_concurrent_per_user = 1 # 每个用户同时最多可授权几个流水线
```
### 运行时参数
```bash
# 申请权限
hbw pipeline run --uncover-secret
# 指定超时(需管理员权限)
hbw pipeline run --uncover-secret --uncover-timeout 10m
# 带理由(便于审计)
hbw pipeline run --uncover-secret --reason "调试数据库连接失败"
```
## 9. 建议
### 对用户
- **非必要不使用**:能用常规调试手段解决的问题,不要依赖暴露 secret
- **用完后立即结束流水线**:避免权限窗口被意外延长
- **检查日志**:使用结束后检查是否有 secret 被意外记录到持久化存储中
- **轮换凭据**:如果确认 secret 在授权期间被暴露,建议立即轮换相关凭据
### 对管理员
- **定期审计**:每周检查 UNCOVER_SECRET 使用记录,发现异常及时处理
- **设置合理超时**:根据团队需求调整默认超时时间(建议不超过 15 分钟)
- **培训用户**:确保团队成员理解该权限的风险和使用场景
- **与审批系统集成**:对高风险操作增加二次审批流程
## 10. 常见问题
## 98. 作者
```yaml
---
authors:
---
```
## 99. 参见
+1
View File
@@ -0,0 +1 @@
what
Submodule rust-tongsuo deleted from 6248690a45
+85
View File
@@ -0,0 +1,85 @@
#!/bin/bash
#Implicit set -euo pipefail
# function to be used by any pipeline stage
function get_custom_library() {
curl -LO https://archive.ppy.sh/$2/$1.tar.gz
}
# @pipeline
prepare_dotnet() {
curl -L https://dot.net/v1/dotnet-install.sh | bash
bake_info "Successfully installed dotnet"
}
# @pipeline
fetch_source() {
git clone https://github.com/ppy/osu.git --depth 1
}
# @pipeline
# @if(WS_PIPELINE_ARCH == amd64)
# @timeout(720)
# @retry(3, 5)
# @parallel
fetch_library() {
get_custom_library ffmpeg amd64
get_custom_library libesqlite3 amd64
get_custom_library libbass amd64
}
# @pipeline
# @fallible
# @parallel
fetch_osu_framework() {
cd ..
git clone https://github.com/ppy/osu-framework.git --depth 1
}
# @pipeline
# @timeout(600)
restore_workload() {
cd osu
dotnet workload restore
}
# @pipeline
# @export
set_environment() {
export DOTNET_ENVIRONMENT=Production
}
# @pipeline
# @export
build() {
dotnet build osu.Desktop
dotnet publish osu.Desktop -o osu-build --sc
}
# @pipeline
# @loop(3)
test() {
dotnet test
}
# @pipeline
# @parallel
# @after(fetch_library)
compress_library() {
mkdir -p osu-native
cp osu-build/*.so ../osu-native
tar -cf osu-native osu-native.tar
zstd osu.tar
}
# @pipeline
# @after(test)
compress_artifact() {
tar -cf osu osu.tar
zstd osu.tar
}
+368
View File
@@ -0,0 +1,368 @@
# finalize.yml.tmpl
#
# 蜜饼工坊 (HoneyBiscuitWorkshop) 构建流水线 - 完成阶段配置
#
# 版本号,用于格式兼容性检查
# 版本迁移系统内置,预期 1.0 后不再变更 schema
version: "0.0.1"
# =============================================================================
# 插件定义
# =============================================================================
# 插件在 finalize 阶段执行特定任务(如推送镜像、上传制品等)
# 插件可以是 Shell 脚本或 Rhai 脚本,由文件扩展名自动检测
#
# 插件来源格式:
# git://<git-url>@<tag>/<commit> - Git 仓库(在 prebake 阶段拉取)
# https://<url> - HTTPS 下载(支持脚本直接下载)
# file://<path> - 本地文件(在 finalize.begin 阶段加载)
# http://<url> - HTTP 下载(计划支持)
# hg://<url> - Mercurial 仓库(计划支持)
#
# 插件隔离:
# - Docker/VM 环境:允许任意插件,由环境提供隔离
# - Bare-metal 环境:需要额外权限配置(PIP: Pipeline Implicit Parameters
#
# 插件通信:
# - Shell 脚本:通过命令行参数和环境变量接收配置
# - Rhai 脚本:通过注入的对象/函数接收配置(WIP)
# - 返回值:通过退出码表示成功/失败
# - 日志:会被捕获但不作为执行证据
plugin:
# 插件逻辑名称,用于在 artifact.publish 和 notification 中引用
# 名称必须唯一,不可重复定义
docker-push:
# 插件来源 URI,包含版本锁定
# @main/1234abcd 表示 main 分支的 1234abcd 提交
# 如果该提交不在指定分支中,则执行失败
"git://github.com/user/hbw-finalize-docker-push@main/1234abcd":
# 认证主机,仅用于认证目的
# 如果 publish 中指定了 image 且包含主机部分,此处可省略
host: "docker.io"
# 用户凭证,支持模板变量
# {{ variable.xxx }} 为用户配置值
# {{ secret.xxx }} 为从 Vault 获取的密钥
user: {{ variable.dockerhub.user }}
token: {{ secret.dockerhub.token }}
# fallible: 是否允许失败
# true - 插件失败不会导致构建失败,错误不会向上传播
# false - 插件失败会导致构建失败
# 语义与 bake.sh 的 @fallible 装饰器相同
fallible: false
# 镜像安全检查插件示例(HTTPS 下载)
image-check:
"https://example.com/trivy/trivy.sh":
user: {{ variable.trivy.user }}
password: {{ secret.trivy.password }}
fallible: true
# 短信通知插件示例(本地 Rhai 脚本)
sms-notify:
"file://.workshop/plugins/sms-notify.rhai":
provider: "aliyun"
access_key: {{ secret.aliyun.sms_key }}
access_secret: {{ secret.aliyun.sms_secret }}
# =============================================================================
# 通知配置
# =============================================================================
# 通知采用优先级组机制:
# - 优先级为整数,范围 0 ~ INT_MAX
# - 数值越小优先级越高
# - 同一优先级组内的通知方式并行执行
# - 当组内任一方式失败时,触发下一优先级组(fallback)
# - fallible: true 可阻止错误传播,不触发 fallback
#
# 系统级 fallback(在用户配置之外):
# 1. 站内通知(存储在数据库,显示在 Baker Dashboard
# 2. 系统邮件(用户创建时配置)
# 3. 放弃通知,管理员接收告警(如已配置)
#
# 优先级组为空时会产生警告,但不会自动修正
# 建议将优先级设为 0、1、2... 等连续值
notification:
# 最高优先级组 - 首选通知方式
0:
# Webhook 通知
webhook:
url: "https://example.com/hook"
# schema 定义负载格式:
# - "default": 使用插件内置的默认格式
# - "custom": 自定义格式,需要提供 on_* 模板
# 当设置 on_success/on_failure 时,schema 隐式为 "custom"
schema: "default"
# Satori 协议通知(如 Koishi
satori:
url: "http://127.0.0.1:5140/satori/v1/message.create"
token: "Bearer {{ secret.koishi_token }}"
schema: "custom"
# on_success: 构建成功时发送的内容
# 支持模板变量:
# {{ build.status }} - 构建状态
# {{ build.duration }} - 构建耗时
# {{ target.xxx }} - 目标相关变量
on_success: |
{
"channel_id": "{{ target.group_id }}",
"content": "🍯 HBW 构建完成\n状态: {{ build.status }}\n耗时: {{ build.duration }}"
}
# on_failure: 构建失败时发送的内容
on_failure: |
{
"channel_id": "{{ target.group_id }}",
"content": "🍯 HBW 构建失败!\n状态: {{ build.status }}\n耗时: {{ build.duration }}"
}
# on_finish_of: 在指定阶段完成时发送通知
# 可用于在构建过程中发送进度通知
# 阶段命名规则:
# - prebake/finalize: 第二字段为静态名称,如 "prebake.ready"
# - bake: 第二字段为 bake.sh 中的函数名,如 "bake.build"
# 每个条件有独立的 content,会在对应时机发送
on_finish_of:
stage: "prebake.ready"
content: |
{
"channel_id": "{{ target.group_id }}",
"content": "🍯 HBW 准备完毕\n状态: {{ build.status }}\n耗时: {{ build.duration }}"
}
# 次优先级组 - 备用通知方式(当优先级 0 中任一方式失败时触发)
1:
# 邮件通知(内置插件)
mail:
to: "user@gmail.com"
from:
host: "mail.163.com"
mode: "smtp"
# 加密模式:
# - "TLS": 使用 TLS 加密(端口 465
# - "StartTLS": 使用 STARTTLS(端口 587
# - "no": 不加密(不推荐)
secure: "TLS"
username: {{ variable.mail.user }}
password: {{ secret.mail.password }}
schema: "default"
# 插件也可以直接作为通知方式使用
# 需要插件支持通知触发器(on_success/on_failure/on_finish_of
sms-notify:
to: "+8610012345678"
schema: "custom"
policy:
- on_success: "[HBW Notify] Build Succeeded: {{ build.status }} ({{ build.duration }})"
to: "+8610012345678"
- on_failure: "[HBW Notify] Build Failed: {{ build.status }} ({{ build.duration }})"
to: "+8610012345679"
- on_finish_of:
stage: "bake.build"
content: "[HBW Notify] Build Progress: {{ build.status }} ({{ build.duration }})"
to: "+8610012345677"
fallible: true
# =============================================================================
# 制品定义
# =============================================================================
# 制品是构建产生的输出文件,可被存储、发布或清理
#
# 制品 ID
# - 可选,如未指定则由系统自动生成
# - 格式:MD5(PUID + PATH)
# - PUID: Pipeline Unique Identifier
#
# 制品路径:
# - 支持通配符,会被编译为正则表达式
# - 在配置加载时检查语法,在执行时编译和匹配
# - 总限制时间 1 秒(编译 + 匹配)
# - 一个路径匹配 = 一个制品元素
# - 空路径表示非文件系统制品(如 Docker 镜像)
#
# 制品传输:
# - 通过 hard-link/rsync 传输到插件工作空间
# - 插件可自定义传输方式
artifact:
# 示例制品 1:普通文件制品
- # 制品 ID,可选
# 如不指定,系统自动生成 MD5(PUID + PATH)
id: "<SOME_ARTIFACT_ID>"
# 制品路径,支持 glob 模式
# 匹配的每个文件/目录作为独立制品
path: "/workspace/target/release/*"
# 保留时长
# TODO
retention: "7d"
# 压缩方式:
# - "zstd": Zstandard 压缩(推荐)
# - "gzip": Gzip 压缩
# - "none": 不压缩
# - "dir": 保留为目录结构(用于需要完整目录树的场景)
compression: "zstd"
# 触发条件:
# - "success": 构建成功时处理
# - "failure": 构建失败时处理(WIP
# - "always": 总是处理(WIP
# 支持多个条件
on:
- success
# 示例制品 2:Docker 镜像制品
- id: "<ANOTHER_ARTIFACT_ID>"
# 空路径表示非文件系统制品
# 用于 Docker 镜像、远程制品等
# 此类制品的"内容"由 publish 定义
path: ""
retention: "5d"
compression: "none"
on:
- success
# 发布配置
# 制品可以发布到一个或多个目标
# 多个发布目标并行执行(除非受 Token 约束)
publish:
# 引用 plugin 部分定义的插件名称
docker-push:
# 覆盖/补充插件配置
# 镜像名称,包含 registry 地址
# 如 plugin 中已定义 host,此处可省略 registry 部分
image: "docker.io/user/image:tag"
# 同一制品可以发布到多个插件
# 通过 Token/Lock 机制协调访问
image-check:
image: "docker.io/user/image:tag"
# =============================================================================
# 钩子定义
# =============================================================================
# 在 finalize 阶段执行的前置/后置钩子
# 可用于清理、预处理、后处理等操作
#
# 执行时机:
# - early: 制品收集之前执行
# - late: 所有操作完成后执行
#
# 命令类型:
# - 普通命令:直接执行 shell 命令
# - 插件命令:以 "plugin:" 开头,调用已定义的插件
hooks:
# 早期钩子:在制品收集之前执行
early:
- # 钩子名称,用于日志和调试
name: "cleanup temp files before artifact collection"
# 执行命令
command: "rm **/.tmp"
# 工作目录
working_dir: "/workspace"
# 插件类型钩子示例
- name: "Early hook internal plugin"
command: "plugin:early-hook-internal"
# 超时时间(秒)
timeout: 600
# 后期钩子:在所有操作完成后执行
late:
- name: "Build completion notification"
command: "echo 'Build completed!'"
# =============================================================================
# 清理配置
# =============================================================================
# 清理策略控制构建完成后的资源回收
# WIP: 更多选项待设计
cleanup:
# 清理策略:
# - "auto": 自动清理(默认)
# - "manual": 手动清理
# - "on_failure": 仅失败时清理(WIP
policy: "manual"
# =============================================================================
# 模板变量参考
# =============================================================================
# finalize.yml 支持以下模板变量
#
# 用户配置:
# {{ variable.xxx }} - 用户在 Baker 中配置的值
#
# 密钥(来自 Vault):
# {{ secret.xxx }} - 从 Vault 获取的密钥值
# - 仅存在于内存中,不写入磁盘
# - 日志中自动遮蔽
# - 需要 PIPELINE_UNCOVER_SECRET 权限才能查看(需 2FA
#
# 构建信息:
# {{ build.status }} - 构建状态(动态,表示最后确认的状态)
# {{ build.duration }} - 构建耗时
# {{ build.stage }} - 当前/最后阶段
#
# 目标信息:
# {{ target.xxx }} - 目标相关变量
#
# 注意:
# - 变量可用性取决于执行阶段
# - 在 prebake.ready 阶段,build.duration 可能不可用
# - 使用不可用变量时行为 TBD
# =============================================================================
# 与 prebake.yml 和 bake.sh 的关系
# =============================================================================
# 完整的 HBW 流水线包含三个阶段:
#
# 1. prebake (prebake.yml)
# - 环境准备:Docker/Firecracker/Bare-metal
# - 依赖安装:系统包、语言包
# - 环境变量设置
# - 缓存配置
# - 输出:准备好的构建环境
#
# 2. bake (bake.sh)
# - 构建执行:编译、测试、打包
# - 通过 @decorator 控制执行流程
# - 输出:构建产物
#
# 3. finalize (finalize.yml)
# - 制品收集:收集构建产物
# - 制品发布:推送到目标位置
# - 通知发送:通知构建结果
# - 资源清理:回收临时资源
# - 输出:发布的制品、通知记录
#
# 数据流:
# prebake → bake → finalize
# 环境变量从 prebake 传递到 bake
# 制品从 bake 传递到 finalize
# Secret 在所有阶段可用(通过 {{ secret.xxx }}
# =============================================================================
# 示例:完整流水线配置
# =============================================================================
# 项目 .workshop 目录通常包含:
# - prebake.yml 或 prebake.yml.tmpl
# - bake.sh 或 bake.sh.tmpl
# - finalize.yml 或 finalize.yml.tmpl
# - .env 环境变量(不应包含 secret)
#
# 模板文件(.tmpl 后缀)包含占位符,会在首次运行时生成实际配置
# 系统会通过 LLM 或模板引擎填充这些占位符
# vim: set ft=yaml ts=2 sw=2 et:
+243
View File
@@ -0,0 +1,243 @@
# prebake.yaml.tmpl
#
# 术语说明:
# 可选:该字段可以不给出
# 默认为...:隐含“可选”
# 留空:尚未定义,留作后续
# 版本号,用于格式兼容性检查
# 到目前为止,"1.0"的版本号并不意味着schema就这么确定下来
version: "1.0"
# 构建环境定义
environment:
# 构建机类型:docker | firecracker | custom | baremetal
builder: "docker"
# 构建机配置,与builder一致的被启用。
baremetal: {}
docker:
# 镜像名,与dockerfile互斥
image: "rust:1.70-slim"
# Dockerfile路径,相对于项目根目录,与image互斥
dockerfile: "/Dockerfile"
# Docker构建参数,可选
build_args:
RUST_VERSION: "1.70"
firecracker: {}
# TODO: 定义firecracker结构
custom:
# 自定义构建机名称
name: "my-custom-builder"
# 设置脚本,相对于项目根目录
setup_script: "setup-builder.sh"
# 清洁脚本,相对于项目根目录
cleanup_script: "cleanup-builder.sh"
# 硬件资源要求,满足最小要求的构建机可以被选中
resources:
cpu: 2 # CPU核心数,默认为1
memory: "2G" # 内存大小,也可以写作2048MB,默认为"1G"
disk: "10G" # 磁盘空间,也可以写作10240MB,默认为"1G"
architecture: "x86_64" # 架构要求,只能是"noarch"或{"x86_64"/"amd64", "arm64"/"aarch64", "riscv64", "loongarch64"/"loong64"}中的一个或几个
# TODO: 重新设计GPU字段
# gpu: false # 是否需要GPU,默认为false
# gpu_type: "cuda" # GPU类型(如果需要),可以是"cuda", "rocm", "vulkan", "oneapi"中的一个或几个
tags: # 自定义标签,只有满足标签的构建机可以被选中
- "custom_tag"
# 网络配置
network:
enabled: true # 启用网络,对baremetal不适用,默认为true
outbound: true # 允许出站连接,对baremetal不适用,默认为true
dns_servers: # 指定构建机的DNS服务器,对baremetal不适用,custom应该自行处理DNS问题,可选
- "8.8.8.8"
- "1.1.1.1"
proxies: # 代理配置,暂时留空,可选
# 构建环境初始化
bootstrap:
# 构建用户,默认为 vulcan(火神)
# 设定为 "root" 表示忽略用户切换并禁用安全特性,需要 PIPELINE_PRIVILEGED 权限
user: "vulcan"
# 工作空间
workspace:
# 工作目录路径
path: "/home/vulcan/workspace"
# 路径不可用时回退到 /workspace
# 即使路径可用,也会在 /workspace 建立软链接
fallback: true
# 自定义 sudoers 文件内容
# 这会覆盖默认行为,可能绕过安全机制
# 需要 PIPELINE_CUSTOM_SUDOERS 权限
sudoers: "vulcan ALL=(ALL:ALL) NOPASSWD: ALL"
# 自定义 doas 文件内容
# 这会覆盖默认行为,可能绕过安全机制
# 与 sudoers 共享 PIPELINE_CUSTOM_SUDOERS 权限
doas: "permit nopass vulcan as root"
# 自定义 bootstrap 脚本(与上述所有字段互斥)
# 支持多种来源:
# workspace:///.workshop/bootstrap.sh - 项目内文件
# file:///bin/bootstrap.sh - 环境内文件
# server://bootstrap.sh - 由服务器提供
# https://example.com/bootstrap.sh - http/https网络下载,请确保构建环境可以连接到该URL
# (content) - 直接给出内容,当文本开头无法匹配任何scheme时采用
# 需要 PIPELINE_CUSTOM_BOOTSTRAP 权限
custom: "server://bootstrap-alpine.sh"
# 依赖定义,安装依赖时按system-user顺序进行
dependencies:
# Repology 端点配置,用于将 UPM 包名解析为系统包名
# 可选值:disabled, none, local, remote, server, default
# - disabled: 完全禁用 Repology
# - none: 不解析,直接使用原始包名
# - local: 使用本地 Repology 服务器
# - remote: 使用远程 Repology API
# - server: 由服务器提供 Repology 服务
# - default: 默认行为(通常等同于 local)
config:
repology_endpoint: "default"
# 系统包依赖,按包管理器枚举给出,只应该给出与环境契合的字段。
# 根据security.drop-after的配置,在此阶段一般具有root或免密root权限
# 不适用于裸机
system:
apt:
packages: # 安装的软件包
- "git"
- "curl"
- "build-essential"
- "pkg-config"
- "libssl-dev"
repositories: # 该字段按serde Value原样传递给插件,config.rs不做解析
- url: "ppa:custom/ppa" # 自定义PPAUbuntu
- url: "https://download.docker.com/linux/ubuntu" # DEB822
key: "https://download.docker.com/linux/ubuntu/gpg"
mirror: "https://mirrors.tuna.tsinghua.edu.cn" # 指定软件包镜像源,可选
pacman:
packages:
- "git"
- "curl"
- "base-devel"
- "pkg-config"
- "openssl"
repositories:
- server: "https://mirrors.tuna.tsinghua.edu.cn/arch4edu/$arch" # 自定义仓库(Arch
name: "arch4edu"
keypackage: "arch4edu-keyring"
mirror: "https://mirrors.tuna.tsinghua.edu.cn" # 指定软件包镜像源,可选
#dnf:
# 略
# 用户依赖
# 根据security.drop-after的配置,在此阶段一般不具有root权限
# 例外:语言依赖会在system中产生隐式依赖
user:
# 以下字段按serde Value原样传递给插件,config.rs不做解析
# rust:
# type: "rustup" # 此时会向系统引入隐式的rustup依赖
# toolchain: "nightly-x86_64-unknown-linux-gnu"
# manifest: "Cargo.toml"
# lock: "Cargo.lock"
# python:
# type: "uv"
# python: "python3.14"
# index: "http://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
# directory: ".venv"
# env: # 优先,高级选项
# UV_PYTHON: "python3.14"
# UV_INDEX: "http://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
# UV_DIRECTORY: ".venv"
# manifest: "requirements.txt" # 或pyproject.toml
# nix: {}
# # TBD: 没用过lol
# # PR welcome
custom: # 绝对的自定义,与上述全部配置互斥(custom独占)
- command: "cargo fetch"
working_dir: "/tmp"
# 环境变量配置
envvars:
# 构建环境变量,只在 prebake 阶段有效
# 用于控制 prebake 阶段的行为(apt/cargo/rustc 等)
prebake:
RUST_BACKTRACE: "full" # Rust 调试
CARGO_NET_GIT_FETCH_WITH_CLI: "true" # Cargo 配置
NODE_ENV: "production" # Node 环境
# 运行时环境变量(会传递给 bake 阶段)
# 这些变量会与项目中的 .env 文件合并后,作为最终环境
bake:
# 支持 secret 引用,格式: {{ secret.<key> }}
DATABASE_URL: "postgresql://user:{{ secret.dbpassword }}@localhost/db"
LOG_LEVEL: "info"
# 特殊变量:指定项目中 .env 文件的位置
# 系统会读取该文件,并将其中的变量与上面定义的变量合并
# 合并规则:prebake.yml 中定义的变量优先级更高,会覆盖 .env 中的同名变量
# 此变量本身不会传递给 bake 阶段
# .env不应该包含任何 secret
__ENV_FILE: ".env"
# 注意:
# - secret 只存在于内存中,不会写入磁盘
# - 日志中会自动遮蔽 secret 的值
# - 如需临时查看 secret,可使用 PIPELINE_UNCOVER_SECRET 权限(需 2FA
# 缓存配置
cache:
# 缓存目录
directory:
- path: "/usr/local/cargo"
strategy: "always" # 在"always","readonly","clean"和"never"中选择一个,可以被流水线启动时配置覆盖,默认为"always"
mode: "zstd" # 在"gzip","zstd","none"和"dir"中选择一个
- path: "/root/.npm"
strategy: "clean"
mode: "none"
- path: "/app/target"
strategy: "always"
mode: "dir"
# 缓存策略
strategy:
ttl_days: 30 # 缓存生存时间
max_size_gb: 20 # 最大缓存大小
cleanup_policy: "lru" # 清理策略:lru, fifo, size
# 安全配置
security:
#
drop-after: ""
# 留空
# 钩子脚本
hooks:
# 依赖安装前
early:
- command: "echo 'Starting dependency installation'"
name: ""
# 依赖安装后
late:
- command: "cargo fetch"
working_dir: "/tmp"
# 元数据
metadata:
description: "Rust project build environment"
maintainer: "team@example.com"
# vim: set ft=yaml ts=2 sw=2 et:
+3
View File
@@ -0,0 +1,3 @@
temp
examples
quicktest.sh
+5551
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
[package]
name = "workshop-baker"
version = "0.1.0"
edition = "2024"
[lib]
name = "workshop_baker"
path = "src/lib.rs"
[[bin]]
name = "workshop-baker"
path = "src/main.rs"
[features]
default = []
integration-tests = []
[[test]]
name = "engine_integration"
path = "tests/engine_integration.rs"
[dependencies]
anyhow = "1.0.100"
bollard = { version = "0.20.0", features = ["buildkit", "chrono", "ssh"] }
cgroups-rs = "0.5.0"
chrono = { version = "0.4.42", features = ["serde"] }
clap = { version = "4.5.53", features = ["derive"] }
config = "0.15.19"
duration-str = "0.21.0"
env_logger = "0.11.8"
glob = "0.3.2"
futures-util = "0.3.31"
lazy_static = "1.5.0"
lettre = { version = "0.11", features = ["tokio1-native-tls"] }
log = "0.4.28"
minijinja = "2.14.0"
regex = "1.12.2"
semver = "1.0.27"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.148"
serde_yaml = "0.9.34"
shlex = "1.3.0"
tar = "0.4.44"
tempfile = "3.24.0"
thiserror = "2.0.17"
tokio = { version = "1.48.0", features = ["full"] }
topological-sort = "0.2.2"
async-trait = "0.1.87"
uuid = { version = "1.19.0", features = ["v4"] }
libc = "0.2"
os_info = "3.14.0"
privdrop = "0.5.6"
reqwest = { version = "0.12", features = ["json"] }
which = "8.0.2"
gix = "0.81.0"
sha2 = "0.10"
flate2 = "1.0"
zstd = "0.13"
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "parser_bench"
harness = false
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
import os
import random
import string
OUTPUT_DIR = "/tmp"
DECORATORS = [
"# @pipeline",
"# @fallible",
"# @parallel",
"# @export",
"# @timeout(60)",
"# @retry(3, 5)",
"# @if(condition)",
"# @after(dep)",
"# @pipe(step1, step2)",
"# @health(/health)",
"# @loop(10)",
]
FUNCTION_BODIES = [
"echo 'Processing...';",
"local var=$(date +%s);",
"if [ -f /tmp/test ]; then echo 'exists'; fi;",
"for i in $(seq 1 10); do echo $i; done;",
"case $1 in start) echo 'starting';; stop) echo 'stopping';; esac;",
"read -r line < /dev/stdin;",
"export PATH=$PATH:/usr/local/bin;",
"set -e; set -u;",
"trap 'echo error' ERR;",
"cd /tmp || exit 1;",
"local result=$(grep -r 'pattern' /var/log/ 2>/dev/null);",
'for f in /tmp/*.tmp; do rm -f "$f"; done',
"if [[ $var -gt 100 ]]; then echo 'large'; fi;",
'while read -r line; do echo "$line"; done < /etc/passwd;',
'select opt in a b c; do echo "Selected $opt"; break; done;',
]
def random_string(length=8):
return "".join(random.choices(string.ascii_letters + string.digits, k=length))
def generate_function(body_lines):
lines = []
func_name = f"func_{random_string(6)}"
for _ in range(random.randint(0, 3)):
lines.append(random.choice(DECORATORS))
lines.append(f"{func_name}() {{")
for _ in range(body_lines):
lines.append(" " + random.choice(FUNCTION_BODIES))
lines.append("}")
lines.append("")
return lines
def generate_script(target_lines, num_functions, target_bytes):
lines = []
total_lines = 0
body_per_func = max(1, (target_lines - num_functions * 3) // num_functions)
for _ in range(num_functions):
func_lines = generate_function(body_per_func)
lines.extend(func_lines)
total_lines += len(func_lines)
while (
total_lines < target_lines
or len("\n".join(lines).encode("utf-8")) < target_bytes
):
lines.append(random.choice(FUNCTION_BODIES))
total_lines += 1
return "\n".join(lines)
def main():
sizes = [
("small", 1000, 10, 50_000),
("medium", 5000, 50, 500_000),
("large", 10000, 100, 950_000),
]
for name, lines, funcs, target_bytes in sizes:
script = generate_script(lines, funcs, target_bytes)
filepath = os.path.join(OUTPUT_DIR, f"bench_{name}.sh")
with open(filepath, "w") as f:
f.write(script)
actual_bytes = len(script.encode("utf-8"))
actual_lines = len(script.splitlines())
print(f"Generated {filepath}: lines={actual_lines}, bytes={actual_bytes}")
if __name__ == "__main__":
main()
+61
View File
@@ -0,0 +1,61 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use std::fs;
use std::path::Path;
fn parse_large_script(c: &mut Criterion) {
let script_path = Path::new("/tmp/bench_large.sh");
let script_content = fs::read_to_string(script_path)
.expect("Failed to read benchmark script. Run `python3 benches/generate.py` first.");
let byte_size = script_content.len();
let line_count = script_content.lines().count();
println!("Benchmark file: {} lines, {} bytes", line_count, byte_size);
c.bench_function("parse_script_large", |b| {
b.iter(|| {
let result =
workshop_baker::bake::parser::parse_script(black_box(&script_content.clone()));
black_box(result)
});
});
}
fn parse_medium_script(c: &mut Criterion) {
let script_path = Path::new("/tmp/bench_medium.sh");
let script_content = fs::read_to_string(script_path)
.expect("Failed to read benchmark script. Run `python3 benches/generate.py` first.");
c.bench_function("parse_script_medium", |b| {
b.iter(|| {
let result =
workshop_baker::bake::parser::parse_script(black_box(&script_content.clone()));
black_box(result)
});
});
}
fn parse_small_script(c: &mut Criterion) {
let script_path = Path::new("/tmp/bench_small.sh");
let script_content = fs::read_to_string(script_path)
.expect("Failed to read benchmark script. Run `python3 benches/generate.py` first.");
c.bench_function("parse_script_small", |b| {
b.iter(|| {
let result =
workshop_baker::bake::parser::parse_script(black_box(&script_content.clone()));
black_box(result)
});
});
}
criterion_group!(
benches,
parse_small_script,
parse_medium_script,
parse_large_script
);
criterion_main!(benches);
+5
View File
@@ -0,0 +1,5 @@
FROM alpine:latest
RUN echo "Hello from Docker!" && \
aps add --no-cache curl && \
curl --version
CMD ["sh", "-c", "echo 'Container started' && sleep 3600"]
+240
View File
@@ -0,0 +1,240 @@
# prebake.yaml.tmpl
#
# 术语说明:
# 可选:该字段可以不给出
# 默认为...:隐含“可选”
# 留空:尚未定义,留作后续
# 版本号,用于格式兼容性检查
version: "1.0"
# 构建环境定义
environment:
# 构建机类型:docker | firecracker | custom | baremetal
builder: "docker"
# 构建机配置,与builder一致的被启用。
baremetal: {}
docker:
# 镜像名,与dockerfile互斥
image: "rust:1.70-slim"
# Dockerfile路径,相对于项目根目录,与image互斥
dockerfile: "/Dockerfile"
# Docker构建参数,可选
build_args:
RUST_VERSION: "1.70"
firecracker: {}
# TODO: 定义firecracker结构
custom:
# 自定义构建机名称
name: "my-custom-builder"
# 设置脚本,相对于项目根目录
setup_script: "setup-builder.sh"
# 清洁脚本,相对于项目根目录
cleanup_script: "cleanup-builder.sh"
# 硬件资源要求,满足最小要求的构建机可以被选中
resources:
cpu: 2 # CPU核心数,默认为1
memory: "2G" # 内存大小,也可以写作2048MB,默认为"1G"
disk: "10G" # 磁盘空间,也可以写作10240MB,默认为"1G"
architecture: "x86_64" # 架构要求,只能是"noarch"或{"x86_64"/"amd64", "arm64"/"aarch64", "riscv64", "loongarch64"/"loong64"}中的一个或几个
# TODO: 重新设计GPU字段
# gpu: false # 是否需要GPU,默认为false
# gpu_type: "cuda" # GPU类型(如果需要),可以是"cuda", "rocm", "vulkan", "oneapi"中的一个或几个
tags: # 自定义标签,只有满足标签的构建机可以被选中
- "custom_tag"
# 网络配置
network:
enabled: true # 启用网络,对baremetal不适用,默认为true
outbound: true # 允许出站连接,对baremetal不适用,默认为true
dns_servers: # 指定构建机的DNS服务器,对baremetal不适用,custom应该自行处理DNS问题,可选
- "8.8.8.8"
- "1.1.1.1"
proxies: # 代理配置,暂时留空,可选
# 构建环境初始化
bootstrap:
# 构建用户,默认为 vulcan(火神)
# 设定为 "root" 表示忽略用户切换并禁用安全特性,需要 PIPELINE_PRIVILEGED 权限
user: "vulcan"
# 工作空间
workspace:
# 工作目录路径
path: "/home/vulcan/workspace"
# 路径不可用时回退到 /workspace
# 即使路径可用,也会在 /workspace 建立软链接
fallback: true
# 自定义 sudoers 文件内容
# 这会覆盖默认行为,可能绕过安全机制
# 需要 PIPELINE_CUSTOM_SUDOERS 权限
sudoers: "vulcan ALL=(ALL:ALL) NOPASSWD: ALL"
# 自定义 doas 文件内容
# 这会覆盖默认行为,可能绕过安全机制
# 与 sudoers 共享 PIPELINE_CUSTOM_SUDOERS 权限
doas: "permit nopass vulcan as root"
# 自定义 bootstrap 脚本(与上述所有字段互斥)
# 支持多种来源:
# workspace:///.workshop/bootstrap.sh - 项目内文件
# file:///bin/bootstrap.sh - 环境内文件
# server://bootstrap.sh - 由服务器提供
# https://example.com/bootstrap.sh - http/https网络下载,请确保构建环境可以连接到该URL
# (content) - 直接给出内容,当文本开头无法匹配任何scheme时采用
# 需要 PIPELINE_CUSTOM_BOOTSTRAP 权限
custom: "server://bootstrap-alpine.sh"
# 依赖定义,安装依赖时按system-user顺序进行
dependencies:
# Repology 端点配置,用于将 UPM 包名解析为系统包名
# 可选值:disabled, none, local, remote, server, default
# - disabled: 完全禁用 Repology
# - none: 不解析,直接使用原始包名
# - local: 使用本地 Repology 服务器
# - remote: 使用远程 Repology API
# - server: 由服务器提供 Repology 服务
# - default: 默认行为(通常等同于 local)
config:
repology_endpoint: "default"
# 系统包依赖,按包管理器枚举给出,只应该给出与环境契合的字段。
# 根据security.drop-after的配置,在此阶段一般具有root或免密root权限
# 不适用于裸机
system:
apt:
packages: # 安装的软件包
- "git"
- "curl"
- "build-essential"
- "pkg-config"
- "libssl-dev"
repositories: # 该字段按serde Value原样传递给插件,config.rs不做解析
- url: "ppa:custom/ppa" # 自定义PPAUbuntu
- url: "https://download.docker.com/linux/ubuntu" # DEB822
key: "https://download.docker.com/linux/ubuntu/gpg"
mirror: "https://mirrors.tuna.tsinghua.edu.cn" # 指定软件包镜像源,可选
pacman:
packages:
- "git"
- "curl"
- "base-devel"
- "pkg-config"
- "openssl"
repositories:
- server: "https://mirrors.tuna.tsinghua.edu.cn/arch4edu/$arch" # 自定义仓库(Arch
name: "arch4edu"
keypackage: "arch4edu-keyring"
mirror: "https://mirrors.tuna.tsinghua.edu.cn" # 指定软件包镜像源,可选
#dnf:
# 略
# 用户依赖
# 根据security.drop-after的配置,在此阶段一般不具有root权限
# 例外:语言依赖会在system中产生隐式依赖
user:
# 以下字段按serde Value原样传递给插件,config.rs不做解析
# rust:
# type: "rustup" # 此时会向系统引入隐式的rustup依赖
# toolchain: "nightly-x86_64-unknown-linux-gnu"
# manifest: "Cargo.toml"
# lock: "Cargo.lock"
# python:
# type: "uv"
# python: "python3.14"
# index: "http://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
# directory: ".venv"
# env: # 优先,高级选项
# UV_PYTHON: "python3.14"
# UV_INDEX: "http://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
# UV_DIRECTORY: ".venv"
# manifest: "requirements.txt" # 或pyproject.toml
# nix: {}
# # TBD: 没用过lol
# # PR welcome
custom: # 绝对的自定义,与上述全部配置互斥(custom独占)
- command: "cargo fetch"
working_dir: "/tmp"
# 环境变量配置
envvars:
# 构建环境变量,只在 prebake 阶段有效
# 用于控制 prebake 阶段的行为(apt/cargo/rustc 等)
prebake:
RUST_BACKTRACE: "full" # Rust 调试
CARGO_NET_GIT_FETCH_WITH_CLI: "true" # Cargo 配置
NODE_ENV: "production" # Node 环境
# 运行时环境变量(会传递给 bake 阶段)
# 这些变量会与项目中的 .env 文件合并后,作为最终环境
bake:
# 支持 secret 引用,格式: {{ secret.<key> }}
DATABASE_URL: "postgresql://user:{{ secret.dbpassword }}@localhost/db"
LOG_LEVEL: "info"
# 特殊变量:指定项目中 .env 文件的位置
# 系统会读取该文件,并将其中的变量与上面定义的变量合并
# 合并规则:prebake.yml 中定义的变量优先级更高,会覆盖 .env 中的同名变量
# 此变量本身不会传递给 bake 阶段
# .env不应该包含任何 secret
__ENV_FILE: ".env"
# 注意:
# - secret 只存在于内存中,不会写入磁盘
# - 日志中会自动遮蔽 secret 的值
# - 如需临时查看 secret,可使用 PIPELINE_UNCOVER_SECRET 权限(需 2FA
# 缓存配置
cache:
# 缓存目录
directory:
- path: "/usr/local/cargo"
strategy: "always" # 在"always","readonly","clean"和"never"中选择一个,可以被流水线启动时配置覆盖,默认为"always"
mode: "zstd" # 在"gzip","zstd","none"和"dir"中选择一个
- path: "/root/.npm"
strategy: "clean"
mode: "none"
- path: "/app/target"
strategy: "always"
mode: "dir"
# 缓存策略
strategy:
ttl_days: 30 # 缓存生存时间
max_size_gb: 20 # 最大缓存大小
cleanup_policy: "lru" # 清理策略:lru, fifo, size
# 安全配置
security:
#
drop-after: ""
# 留空
# 钩子脚本
hooks:
# 依赖安装前
early:
- command: "echo 'Starting dependency installation'"
name: ""
# 依赖安装后
late:
- command: "cargo fetch"
working_dir: "/tmp"
# 元数据
metadata:
description: "Rust project build environment"
maintainer: "team@example.com"
+11
View File
@@ -0,0 +1,11 @@
[pytest]
testpaths = tests/integration
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short
markers =
integration: marks tests as integration tests (deselect with '-m "not integration"')
slow: marks tests as slow running
filterwarnings =
ignore::DeprecationWarning
+62
View File
@@ -0,0 +1,62 @@
# workshop-baker/src
**Generated:** 2026-04-22
**Commit:** 7b3b71a
Baker CLI + execution engine for HoneyBiscuitWorkshop pipelines.
## OVERVIEW
CLI tool and runtime for executing CI/CD pipelines with resource isolation, privilege dropping, and package management integration.
## STRUCTURE
```
src/
├── bake.rs # Top-level bake orchestration
├── bake/ # Script parsing and building
│ ├── parser.rs # @decorator-aware shell parser
│ ├── decorator.rs # @pipeline, @retry, @parallel, etc.
│ ├── builder.rs # Script template builder
│ └── schedule.rs # Function ordering via @after deps
├── engine/ # Execution runtime
│ ├── executor.rs # Script execution with cgroups
│ ├── cgroups.rs # Linux cgroup resource limits
│ ├── repology.rs # Package name resolution
│ ├── pm.rs # Package manager detection
│ └── upm.rs # User package manager (Nix/Guix)
├── prebake/ # Build environment setup
│ ├── config.rs # PrebakeConfig YAML schema
│ ├── stage/ # Bootstrap, DepsSystem, DepsUser, Hooks
│ └── security.rs # Privilege dropping logic
├── types/ # Shared type definitions
│ ├── repology.rs # RepologyEndpoint enum
│ ├── builderconfig.rs
│ ├── cache.rs
│ └── memsize.rs
├── monitor/ # Resource usage monitoring
│ ├── cgroups.rs # cgroup-based usage collection
│ └── jobobject.rs # Windows job objects (stub)
├── daemon.rs # Unix socket daemon (TODO: refactor)
└── lib.rs # CLI struct + module exports
```
## WHERE TO LOOK
| Task | Location |
|------|----------|
| Script parsing with @decorators | `bake/parser.rs:29` - `parse_script()` |
| Decorator types | `bake/decorator.rs` - `Decorator` enum |
| Build execution | `engine/executor.rs` - `Executor::execute_script()` |
| Resource limits | `engine/types.rs:56` - `ResourceLimits` struct |
| Prebake stages | `prebake/stage/` subdirectories |
| Package detection | `engine/pm.rs:detection()` |
| CLI entry | `lib.rs:26` - `cli::Cli` struct |
## CONVENTIONS
- **ExecutionContext**: Passed through all stages, carries task_id, username, env_vars, timeout
- **PrebakeStage**: Ordered enum controlling stage execution sequence (Bootstrap -> Ready)
- **Decorator**: Attached to shell functions via `# @name(args)` comments above function declarations
- **Engine**: Lightweight wrapper around cgroup manager for resource isolation
- **Dual-mode**: CLI commands (prebake, bake, finalize) vs daemon mode via Unix socket
+158
View File
@@ -0,0 +1,158 @@
use crate::bake::parser::Function;
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::ExecutionContext;
use crate::prebake::PrebakeConfig;
use crate::prebake::security::get_drop_after;
use crate::prebake::stage::PrebakeStage;
use crate::types::buildstatus::{write_stage_status, StageInfo, StagePhase, StageResult};
use crate::{Engine, prebake};
use std::path::{PathBuf, Path};
use chrono::Utc;
mod builder;
mod decorator;
pub mod parser;
mod schedule;
pub async fn bake(
script_path: &Path,
bake_base_path: &Path,
prebake_path: &Path,
cli: &Cli,
ctx: &mut ExecutionContext,
event_tx: EventSender,
) -> Result<(), BakeError> {
let script_content = std::fs::read_to_string(script_path)?;
let prebake_content = std::fs::read_to_string(prebake_path)?;
let prebake = prebake::parse(prebake_content.as_str()).map_err(|e| {
log::error!("Failed to parse prebake.yml: {}", e);
log::error!("This is unexpected!");
BakeError::YamlParseError(e)
})?;
let workspace = get_workspace(&prebake);
let functions = parser::parse_script(&script_content)?;
let has_pipeline = functions.pipeline_functions.iter().any(|f| {
f.decorators
.iter()
.any(|d| matches!(d, decorator::Decorator::Pipeline))
});
let trivial = false;
let use_template = true;
let engine = Engine::new();
if let Some(env) = &prebake.envvars {
if let Some(prebake_env) = &env.bake {
ctx.env_vars.extend(prebake_env.clone());
}
}
if !has_pipeline || trivial {
let stage_start = Utc::now();
let function = Function{
name: TRIVIAL_SCRIPT_NAME.to_string(),
decorators: vec![],
body: script_content,
};
let script = builder::build_script(
&function,
bake_base_path,
&workspace,
None,
use_template,
)?;
let result = engine.execute_script(&script, &ctx, &event_tx).await;
let stage_result = if result.is_ok() { StageResult::Success } else { StageResult::Failure };
let _ = write_stage_status(
STANDALONE_STATUS_DIR,
StageInfo {
name: "trivial".to_string(),
phase: StagePhase::Bake,
substage: "trivial".to_string(),
result: stage_result,
duration_ms: Utc::now().signed_duration_since(stage_start).num_milliseconds() as u64,
started_at: stage_start,
finished_at: Utc::now(),
error_message: result.as_ref().err().map(|e| e.to_string()),
},
);
result?;
} else {
let sorted_functions = schedule::sort_function(functions.pipeline_functions)?;
let remaining = functions.remaining_code;
for func in sorted_functions {
let stage_start = Utc::now();
let mut modified_func = func.clone();
modified_func.body = remaining.clone() + &func.body;
let script = builder::build_script(
&modified_func,
bake_base_path,
&workspace,
None,
use_template,
)?;
let result = engine.execute_script(&script, &ctx, &event_tx).await;
let stage_result = if result.is_ok() { StageResult::Success } else { StageResult::Failure };
let _ = write_stage_status(
STANDALONE_STATUS_DIR,
StageInfo {
name: func.name.clone(),
phase: StagePhase::Bake,
substage: func.name.clone(),
result: stage_result,
duration_ms: Utc::now().signed_duration_since(stage_start).num_milliseconds() as u64,
started_at: stage_start,
finished_at: Utc::now(),
error_message: result.as_ref().err().map(|e| e.to_string()),
},
);
result?;
}
}
Ok(())
}
fn get_workspace(prebake_config: &PrebakeConfig) -> PathBuf {
prebake_config
.bootstrap
.as_ref()
.and_then(|b| b.workspace.as_ref())
.map(|ws| {
if ws.fallback {
PathBuf::from(DEFAULT_WORKSPACE)
} else {
PathBuf::from(ws.path.clone())
}
})
.unwrap_or_else(|| PathBuf::from(DEFAULT_WORKSPACE))
}
fn privileged(prebake_config: &PrebakeConfig) -> bool {
if let Some(bootstrap) = &prebake_config.bootstrap {
if bootstrap.user == "root" {
return true;
}
}
let drop_after = get_drop_after(&prebake_config.security).unwrap_or_default();
PrebakeStage::Never == drop_after
}
type TaskResult = Result<(), BakeError>;
type TaskFn = Box<dyn FnOnce() -> TaskResult + Send>;
// TODO: Learn closure and continue
// async fn run<F>(f:F) -> TaskFn {
// let engine = Engine::new();
// engine.execute_script
// }
// fn with_timeout<F>(f: F, duration: i32) -> TaskFn {
// }
+46
View File
@@ -0,0 +1,46 @@
# workshop-baker/src/bake
**Generated:** 2026-04-22
**Commit:** 7b3b71a
Script parsing and build orchestration with decorator DSL.
## STRUCTURE
```
bake/
├── parser.rs # @decorator-aware shell script parser
├── decorator.rs # Decorator enum + 100+ unit tests
├── builder.rs # Script template assembly
└── schedule.rs # Function ordering via @after dependencies
```
## WHERE TO LOOK
| Task | Location |
|------|----------|
| Parse script | `parser.rs:29` - `parse_script()` |
| Decorator types | `decorator.rs` - `Decorator` enum variants |
| Build script | `builder.rs` - template builder |
| Schedule deps | `schedule.rs` - @after resolution |
## DECORATORS
| Decorator | Purpose |
|-----------|---------|
| `@pipeline` | Marks main build function |
| `@timeout(N)` | Execution timeout seconds |
| `@retry(N, M)` | Retry N times, M sec delay |
| `@parallel` | Run concurrently |
| `@fallible` | Failure doesn't fail pipeline |
| `@after(func)` | Dependency ordering |
| `@export` | Export vars to env |
| `@loop(N)` | Repeat N times |
| `@if(COND)` | Conditional execution |
## CONVENTIONS
- **Decorator Syntax**: `# @name(args)` comment above function
- **Parsing**: Shell grammar with decorator annotation extraction
- **Scheduling**: DAG resolution for @after dependencies
- **Tests**: `decorator.rs:175-829` has 100+ inline unit tests
+158
View File
@@ -0,0 +1,158 @@
use crate::bake::decorator::Decorator;
use crate::bake::parser::Function;
use crate::constant::TRIVIAL_SCRIPT_NAME;
use minijinja::{Environment, context};
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
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 }}";
pub fn build_script(
function: &Function,
bake_base: &Path,
target_dir: &Path,
temp_path: Option<PathBuf>,
use_template: bool,
) -> Result<PathBuf, BakeError> {
let name = &function.name;
let trivial = name == TRIVIAL_SCRIPT_NAME;
let body = &function.body;
let export = function
.find_decorator(|d| matches!(d, Decorator::Export).then_some(()))
.is_some();
let condition = function.find_decorator(|d| {
if let Decorator::If(content) = d {
Some(content.clone())
} else {
None
}
});
let temp_path = temp_path.unwrap_or_else(|| PathBuf::from("/tmp"));
let filename = format!("bakefn_{}.sh", name);
let target = target_dir.join(filename);
let bake_base_content = if use_template {
std::fs::read_to_string(bake_base).map_err(|e| BakeError::ScriptGenerationFailed {
script: bake_base.display().to_string(),
reason: e.to_string(),
})?
} else {
TRIVIAL_TEMPLATE.to_string()
};
let condition_code = match condition {
Some(content) => {
format!(
"if [ ! {} ]; then\n exit {}\nfi\n",
content, PIPELINE_SKIP_ERRORCODE
)
}
None => String::new(),
};
let mut preexport_code = String::new();
let mut export_code = String::new();
if !trivial {
if export {
preexport_code = format!("declare -xp > /dev/shm/bakeenv.before.{}\n", name);
export_code = format!(
"declare -xp > /dev/shm/bakeenv.after.{}\ndiff -w --new-line-format='%L' --old-line-format='' --unchanged-line-format='' /dev/shm/bakeenv.before.{} /dev/shm/bakeenv.after.{} > /dev/shm/bakeexport.{}\n",
name, name, name, name
);
}
preexport_code.push_str(format!(". /dev/shm/bakeexport.{}\n", name).as_str());
export_code.push_str(format!("rm -f /dev/shm/bakeenv.*.{}\n", name).as_str());
}
let mut env = Environment::new();
env.add_template("script", bake_base_content.as_str())?;
let script = env.get_template("script")?;
let rendered = if use_template {
script.render(
context! {main => body,condition => condition_code, preexport => preexport_code, name => name, export => export_code},
)?
} else {
script.render(context! {main => body})?
};
let mut temp_file =
NamedTempFile::new_in(&temp_path).map_err(|e| BakeError::ScriptGenerationFailed {
script: temp_path.display().to_string(),
reason: e.to_string(),
})?;
temp_file
.as_file_mut()
.set_permissions(Permissions::from_mode(0o700))
.map_err(|e| BakeError::ScriptGenerationFailed {
script: temp_path.display().to_string(),
reason: e.to_string(),
})?;
std::fs::write(&temp_file, rendered).map_err(|e| BakeError::ScriptGenerationFailed {
script: temp_path.display().to_string(),
reason: e.to_string(),
})?;
temp_file
.persist(&target)
.map_err(|e| BakeError::ScriptGenerationFailed {
script: target.display().to_string(),
reason: e.error.to_string(),
})?;
Ok(target)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_script_with_template() {
let temp_dir = tempfile::tempdir().unwrap();
let bake_base = temp_dir.path().join("bake_base.sh");
std::fs::write(&bake_base, "{{ main }}\n").unwrap();
let function = Function {
name: "test".to_string(),
decorators: vec![],
body: "echo 'hello world'".to_string(),
};
let result = build_script(&function, &bake_base, temp_dir.path(), None, true);
assert!(result.is_ok());
let script_path = result.unwrap();
let content = std::fs::read_to_string(&script_path).unwrap();
assert!(content.contains("echo 'hello world'"));
}
#[test]
fn test_build_script_trivial_mode() {
let temp_dir = tempfile::tempdir().unwrap();
let function = Function {
name: "trivial".to_string(),
decorators: vec![],
body: "echo 'direct output'".to_string(),
};
let result = build_script(
&function,
Path::new("/nonexistent"),
temp_dir.path(),
None,
false,
);
assert!(result.is_ok());
let script_path = result.unwrap();
let content = std::fs::read_to_string(&script_path).unwrap();
assert_eq!(content.trim(), "echo 'direct output'");
}
}
+840
View File
@@ -0,0 +1,840 @@
use lazy_static::lazy_static;
use regex::Regex;
use std::fmt::Display;
use std::fmt::Formatter;
use crate::error::BakeError;
lazy_static! {
// "# @decorator" or "# @decorator(parameters)"
static ref DECORATOR_REGEX: Regex = Regex::new(r"^\s*#\s+@(\w+)\s?(\((.*)\))?").unwrap()
;
static ref UNEXPECTED_ARGUMENT: &'static str = "decorator does not accept arguments";
static ref MISSING_ARGUMENT: &'static str = "decorator requires an argument";
static ref UNKNOWN_DECORATOR: &'static str = "unknown decorator";
static ref INVALID_ARGUMENT: &'static str = "invalid argument";
}
#[derive(Debug, Clone, PartialEq)]
pub enum Decorator {
Unknown, // for general error reporting when the decorator name is not recognized
Pipeline,
If(String),
Fallible,
Loop(usize),
After(String),
Timeout(u32),
Retry(usize, u32),
Export,
Pipe(Vec<String>),
Parallel,
Daemon,
Health(String),
}
impl Display for Decorator {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Decorator::Unknown => write!(f, "@<unknown>"),
Decorator::Pipeline => write!(f, "@pipeline"),
Decorator::If(cond) => write!(f, "@if({})", cond),
Decorator::Fallible => write!(f, "@fallible"),
Decorator::Loop(count) => write!(f, "@loop({})", count),
Decorator::After(func) => write!(f, "@after({})", func),
Decorator::Timeout(timeout) => write!(f, "@timeout({})", timeout),
Decorator::Retry(count, delay) => write!(f, "@retry({}, {})", count, delay),
Decorator::Export => write!(f, "@export"),
Decorator::Pipe(funcs) => write!(f, "@pipe({})", funcs.join(", ")),
Decorator::Parallel => write!(f, "@parallel"),
Decorator::Daemon => write!(f, "@daemon"),
Decorator::Health(check) => write!(f, "@health({})", check),
}
}
}
pub fn parse_decorator(line: &str, line_num: usize) -> Result<Option<Decorator>, BakeError> {
let caps = match DECORATOR_REGEX.captures(line) {
Some(c) => c,
None => return Ok(None), // Actually not a decorator
};
let name = caps.get(1).unwrap().as_str();
let has_arg = caps.get(2).is_some();
let args = caps.get(3).map(|m| m.as_str()).unwrap_or("");
let decorator_noarg = |d: Decorator| -> Result<Option<Decorator>, BakeError> {
if has_arg {
Err(BakeError::DecoratorParseError {
decorator: name.to_string(),
line_num,
reason: UNEXPECTED_ARGUMENT.to_string(),
})
} else {
Ok(Some(d))
}
};
let required_arg = || -> Result<&str, BakeError> {
if !has_arg || args.is_empty() {
Err(BakeError::DecoratorParseError {
decorator: name.to_string(),
line_num,
reason: MISSING_ARGUMENT.to_string(),
})
} else {
Ok(args)
}
};
match name {
"pipeline" => decorator_noarg(Decorator::Pipeline),
"if" => required_arg().map(|a| Some(Decorator::If(a.to_string()))),
"fallible" => decorator_noarg(Decorator::Fallible),
"loop" => required_arg().and_then(|a| {
let count = a.parse().map_err(|_e| BakeError::DecoratorParseError {
decorator: "loop".to_string(),
line_num,
reason: format!("invalid loop count: {}", a),
})?;
Ok(Some(Decorator::Loop(count)))
}),
"after" => required_arg().map(|a| Some(Decorator::After(a.to_string()))),
"timeout" => required_arg().and_then(|a| {
let timeout = a.parse().map_err(|_e| BakeError::DecoratorParseError {
decorator: "timeout".to_string(),
line_num,
reason: format!("invalid timeout: {}", a),
})?;
Ok(Some(Decorator::Timeout(timeout)))
}),
"retry" => required_arg().and_then(|a| {
let parts: Vec<&str> = a.split(',').collect();
if parts.len() != 2 {
Err(BakeError::DecoratorParseError {
decorator: "retry".to_string(),
line_num,
reason: format!("invalid retry args: {}", a),
})
} else {
let count =
parts[0]
.trim()
.parse()
.map_err(|_e| BakeError::DecoratorParseError {
decorator: "retry".to_string(),
line_num,
reason: format!("invalid retry count: {}", parts[0]),
})?;
let delay_str = parts[1].trim();
if delay_str.is_empty() {
Err(BakeError::DecoratorParseError {
decorator: "retry".to_string(),
line_num,
reason: "no delay specified".to_string(),
})
} else {
let delay = delay_str
.parse()
.map_err(|_e| BakeError::DecoratorParseError {
decorator: "retry".to_string(),
line_num,
reason: format!("invalid retry delay: {}", parts[1]),
})?;
Ok(Some(Decorator::Retry(count, delay)))
}
}
}),
"export" => decorator_noarg(Decorator::Export),
"parallel" => decorator_noarg(Decorator::Parallel),
"daemon" => decorator_noarg(Decorator::Daemon),
"health" => required_arg().map(|a| Some(Decorator::Health(a.to_string()))),
"pipe" => required_arg().and_then(|a| {
let functions: Vec<String> = a
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if functions.is_empty() {
Err(BakeError::DecoratorParseError {
decorator: "pipe".to_string(),
line_num,
reason: "no functions specified".to_string(),
})
} else {
Ok(Some(Decorator::Pipe(functions)))
}
}),
d => Err(BakeError::DecoratorParseError {
decorator: d.to_string(),
line_num,
reason: UNKNOWN_DECORATOR.to_string(),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
// =====================================================================
// Tests for Decorator Display implementation
// =====================================================================
#[test]
fn test_decorator_display_pipeline() {
assert_eq!(format!("{}", Decorator::Pipeline), "@pipeline");
}
#[test]
fn test_decorator_display_if() {
assert_eq!(
format!("{}", Decorator::If("cond".to_string())),
"@if(cond)"
);
assert_eq!(
format!("{}", Decorator::If("x > 0".to_string())),
"@if(x > 0)"
);
}
#[test]
fn test_decorator_display_fallible() {
assert_eq!(format!("{}", Decorator::Fallible), "@fallible");
}
#[test]
fn test_decorator_display_loop() {
assert_eq!(format!("{}", Decorator::Loop(5)), "@loop(5)");
assert_eq!(format!("{}", Decorator::Loop(0)), "@loop(0)");
assert_eq!(
format!("{}", Decorator::Loop(usize::MAX)),
format!("@loop({})", usize::MAX)
);
}
#[test]
fn test_decorator_display_after() {
assert_eq!(
format!("{}", Decorator::After("func1".to_string())),
"@after(func1)"
);
assert_eq!(
format!("{}", Decorator::After("build_step_1".to_string())),
"@after(build_step_1)"
);
}
#[test]
fn test_decorator_display_timeout() {
assert_eq!(format!("{}", Decorator::Timeout(30)), "@timeout(30)");
assert_eq!(format!("{}", Decorator::Timeout(0)), "@timeout(0)");
assert_eq!(
format!("{}", Decorator::Timeout(u32::MAX)),
format!("@timeout({})", u32::MAX)
);
}
#[test]
fn test_decorator_display_retry() {
assert_eq!(format!("{}", Decorator::Retry(3, 100)), "@retry(3, 100)");
assert_eq!(format!("{}", Decorator::Retry(0, 0)), "@retry(0, 0)");
assert_eq!(
format!("{}", Decorator::Retry(10, 5000)),
"@retry(10, 5000)"
);
}
#[test]
fn test_decorator_display_export() {
assert_eq!(format!("{}", Decorator::Export), "@export");
}
#[test]
fn test_decorator_display_pipe() {
assert_eq!(format!("{}", Decorator::Pipe(vec![])), "@pipe()");
assert_eq!(
format!("{}", Decorator::Pipe(vec!["f1".to_string()])),
"@pipe(f1)"
);
assert_eq!(
format!(
"{}",
Decorator::Pipe(vec!["f1".to_string(), "f2".to_string()])
),
"@pipe(f1, f2)"
);
assert_eq!(
format!(
"{}",
Decorator::Pipe(vec!["a".to_string(), "b".to_string(), "c".to_string()])
),
"@pipe(a, b, c)"
);
}
#[test]
fn test_decorator_display_parallel() {
assert_eq!(format!("{}", Decorator::Parallel), "@parallel");
}
#[test]
fn test_decorator_display_daemon() {
assert_eq!(format!("{}", Decorator::Daemon), "@daemon");
}
#[test]
fn test_decorator_display_health() {
assert_eq!(
format!("{}", Decorator::Health("http://localhost:8080".to_string())),
"@health(http://localhost:8080)"
);
assert_eq!(
format!("{}", Decorator::Health("/health".to_string())),
"@health(/health)"
);
}
#[test]
fn test_decorator_display_unknown() {
assert_eq!(format!("{}", Decorator::Unknown), "@<unknown>");
}
// =====================================================================
// Tests for parse_decorator - Valid no-argument decorators
// =====================================================================
#[test]
fn test_parse_decorator_pipeline() {
let result = parse_decorator("# @pipeline", 1).unwrap();
assert_eq!(result, Some(Decorator::Pipeline));
}
#[test]
fn test_parse_decorator_fallible() {
let result = parse_decorator("# @fallible", 1).unwrap();
assert_eq!(result, Some(Decorator::Fallible));
}
#[test]
fn test_parse_decorator_export() {
let result = parse_decorator("# @export", 1).unwrap();
assert_eq!(result, Some(Decorator::Export));
}
#[test]
fn test_parse_decorator_parallel() {
let result = parse_decorator("# @parallel", 1).unwrap();
assert_eq!(result, Some(Decorator::Parallel));
}
#[test]
fn test_parse_decorator_daemon() {
let result = parse_decorator("# @daemon", 1).unwrap();
assert_eq!(result, Some(Decorator::Daemon));
}
// =====================================================================
// Tests for parse_decorator - Valid single-argument decorators
// =====================================================================
#[test]
fn test_parse_decorator_if() {
let result = parse_decorator("# @if(condition)", 1).unwrap();
assert_eq!(result, Some(Decorator::If("condition".to_string())));
}
#[test]
fn test_parse_decorator_if_with_complex_condition() {
let result = parse_decorator("# @if(env.STATUS == \"ready\")", 1).unwrap();
assert_eq!(
result,
Some(Decorator::If("env.STATUS == \"ready\"".to_string()))
);
}
#[test]
fn test_parse_decorator_after() {
let result = parse_decorator("# @after(build_step)", 1).unwrap();
assert_eq!(result, Some(Decorator::After("build_step".to_string())));
}
#[test]
fn test_parse_decorator_after_with_underscore() {
let result = parse_decorator("# @after(build_step_1)", 1).unwrap();
assert_eq!(result, Some(Decorator::After("build_step_1".to_string())));
}
#[test]
fn test_parse_decorator_loop() {
let result = parse_decorator("# @loop(5)", 1).unwrap();
assert_eq!(result, Some(Decorator::Loop(5)));
}
#[test]
fn test_parse_decorator_loop_zero() {
let result = parse_decorator("# @loop(0)", 1).unwrap();
assert_eq!(result, Some(Decorator::Loop(0)));
}
#[test]
fn test_parse_decorator_health() {
let result = parse_decorator("# @health(/health)", 1).unwrap();
assert_eq!(result, Some(Decorator::Health("/health".to_string())));
}
#[test]
fn test_parse_decorator_health_url() {
let result = parse_decorator("# @health(http://localhost:8080/health)", 1).unwrap();
assert_eq!(
result,
Some(Decorator::Health(
"http://localhost:8080/health".to_string()
))
);
}
#[test]
fn test_parse_decorator_timeout() {
let result = parse_decorator("# @timeout(30)", 1).unwrap();
assert_eq!(result, Some(Decorator::Timeout(30)));
}
#[test]
fn test_parse_decorator_timeout_zero() {
let result = parse_decorator("# @timeout(0)", 1).unwrap();
assert_eq!(result, Some(Decorator::Timeout(0)));
}
// =====================================================================
// Tests for parse_decorator - Valid multi-argument decorators
// =====================================================================
#[test]
fn test_parse_decorator_retry() {
let result = parse_decorator("# @retry(3, 100)", 1).unwrap();
assert_eq!(result, Some(Decorator::Retry(3, 100)));
}
#[test]
fn test_parse_decorator_retry_with_spaces() {
let result = parse_decorator("# @retry(3,100)", 1).unwrap();
assert_eq!(result, Some(Decorator::Retry(3, 100)));
}
#[test]
fn test_parse_decorator_retry_with_many_spaces() {
let result = parse_decorator("# @retry( 3 , 100 )", 1).unwrap();
assert_eq!(result, Some(Decorator::Retry(3, 100)));
}
#[test]
fn test_parse_decorator_retry_zero_count() {
let result = parse_decorator("# @retry(0, 0)", 1).unwrap();
assert_eq!(result, Some(Decorator::Retry(0, 0)));
}
#[test]
fn test_parse_decorator_pipe() {
let result = parse_decorator("# @pipe(func1)", 1).unwrap();
assert_eq!(result, Some(Decorator::Pipe(vec!["func1".to_string()])));
}
#[test]
fn test_parse_decorator_pipe_multiple() {
let result = parse_decorator("# @pipe(func1, func2, func3)", 1).unwrap();
assert_eq!(
result,
Some(Decorator::Pipe(vec![
"func1".to_string(),
"func2".to_string(),
"func3".to_string()
]))
);
}
#[test]
fn test_parse_decorator_pipe_with_spaces() {
let result = parse_decorator("# @pipe( func1 , func2 )", 1).unwrap();
assert_eq!(
result,
Some(Decorator::Pipe(vec![
"func1".to_string(),
"func2".to_string()
]))
);
}
// =====================================================================
// Tests for parse_decorator - Whitespace handling
// =====================================================================
#[test]
fn test_parse_decorator_with_leading_whitespace() {
let result = parse_decorator(" # @pipeline", 1).unwrap();
assert_eq!(result, Some(Decorator::Pipeline));
}
#[test]
fn test_parse_decorator_with_extra_spaces() {
let result = parse_decorator("# @pipeline", 1).unwrap();
assert_eq!(result, Some(Decorator::Pipeline));
}
#[test]
fn test_parse_decorator_with_tab() {
let result = parse_decorator("#\t@pipeline", 1).unwrap();
assert_eq!(result, Some(Decorator::Pipeline));
}
#[test]
fn test_parse_decorator_no_space_between_hash_and_at() {
// Regex requires at least one whitespace between # and @
// So "#@pipeline" is NOT a valid decorator
let result = parse_decorator("#@pipeline", 1).unwrap();
assert_eq!(result, None);
}
// =====================================================================
// Tests for parse_decorator - Non-decorator lines
// =====================================================================
#[test]
fn test_parse_decorator_no_hash() {
let result = parse_decorator("@pipeline", 1).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_parse_decorator_empty_line() {
let result = parse_decorator("", 1).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_parse_decorator_only_whitespace() {
let result = parse_decorator(" ", 1).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_parse_decorator_regular_comment() {
let result = parse_decorator("# This is a regular comment", 1).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_parse_decorator_code_line() {
let result = parse_decorator("echo 'Hello World'", 1).unwrap();
assert_eq!(result, None);
}
// =====================================================================
// Tests for parse_decorator - Invalid: decorators with wrong arguments
// =====================================================================
#[test]
fn test_parse_decorator_pipeline_with_arg() {
let result = parse_decorator("# @pipeline(something)", 1);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, BakeError::DecoratorParseError { .. }));
if let BakeError::DecoratorParseError {
decorator, reason, ..
} = err
{
assert_eq!(decorator, "pipeline");
assert!(reason.contains("does not accept arguments"));
}
}
#[test]
fn test_parse_decorator_fallible_with_arg() {
let result = parse_decorator("# @fallible(true)", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_export_with_arg() {
let result = parse_decorator("# @export(mode)", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_parallel_with_arg() {
let result = parse_decorator("# @parallel(true)", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_daemon_with_arg() {
let result = parse_decorator("# @daemon(stop)", 1);
assert!(result.is_err());
}
// =====================================================================
// Tests for parse_decorator - Invalid: decorators missing required arguments
// =====================================================================
#[test]
fn test_parse_decorator_if_no_arg() {
let result = parse_decorator("# @if", 1);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, BakeError::DecoratorParseError { .. }));
if let BakeError::DecoratorParseError {
decorator, reason, ..
} = err
{
assert_eq!(decorator, "if");
assert!(reason.contains("requires an argument"));
}
}
#[test]
fn test_parse_decorator_if_empty_parens() {
let result = parse_decorator("# @if()", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_after_no_arg() {
let result = parse_decorator("# @after", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_loop_no_arg() {
let result = parse_decorator("# @loop", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_loop_empty_parens() {
let result = parse_decorator("# @loop()", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_timeout_no_arg() {
let result = parse_decorator("# @timeout", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_retry_no_arg() {
let result = parse_decorator("# @retry", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_retry_only_count() {
let result = parse_decorator("# @retry(3)", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_retry_only_comma() {
let result = parse_decorator("# @retry(,)", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_health_no_arg() {
let result = parse_decorator("# @health", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_pipe_no_arg() {
let result = parse_decorator("# @pipe", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_pipe_empty_parens() {
let result = parse_decorator("# @pipe()", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_pipe_only_commas() {
let result = parse_decorator("# @pipe(,,)", 1);
assert!(result.is_err());
}
// =====================================================================
// Tests for parse_decorator - Invalid: wrong argument types
// =====================================================================
#[test]
fn test_parse_decorator_loop_non_numeric() {
let result = parse_decorator("# @loop(abc)", 1);
assert!(result.is_err());
let err = result.unwrap_err();
if let BakeError::DecoratorParseError { reason, .. } = err {
assert!(reason.contains("invalid loop count"));
}
}
#[test]
fn test_parse_decorator_loop_float() {
let result = parse_decorator("# @loop(3.14)", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_loop_negative() {
let result = parse_decorator("# @loop(-5)", 1);
// usize parsing will fail for negative numbers
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_timeout_non_numeric() {
let result = parse_decorator("# @timeout(abc)", 1);
assert!(result.is_err());
let err = result.unwrap_err();
if let BakeError::DecoratorParseError { reason, .. } = err {
assert!(reason.contains("invalid timeout"));
}
}
#[test]
fn test_parse_decorator_retry_non_numeric_count() {
let result = parse_decorator("# @retry(abc, 100)", 1);
assert!(result.is_err());
let err = result.unwrap_err();
if let BakeError::DecoratorParseError { reason, .. } = err {
assert!(reason.contains("invalid retry count"));
}
}
#[test]
fn test_parse_decorator_retry_non_numeric_delay() {
let result = parse_decorator("# @retry(3, abc)", 1);
assert!(result.is_err());
let err = result.unwrap_err();
if let BakeError::DecoratorParseError { reason, .. } = err {
assert!(reason.contains("invalid retry delay"));
}
}
#[test]
fn test_parse_decorator_retry_empty_delay() {
let result = parse_decorator("# @retry(3, )", 1);
assert!(result.is_err());
let err = result.unwrap_err();
if let BakeError::DecoratorParseError { reason, .. } = err {
assert!(reason.contains("no delay specified"));
}
}
// =====================================================================
// Tests for parse_decorator - Invalid: unknown decorators
// =====================================================================
#[test]
fn test_parse_decorator_unknown() {
let result = parse_decorator("# @unknown", 1);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, BakeError::DecoratorParseError { .. }));
if let BakeError::DecoratorParseError {
decorator,
reason,
line_num,
} = err
{
assert_eq!(decorator, "unknown");
assert_eq!(line_num, 1);
assert!(reason.contains("unknown decorator"));
}
}
#[test]
fn test_parse_decorator_typo_name() {
let result = parse_decorator("# @pipelne", 1);
assert!(result.is_err());
let err = result.unwrap_err();
if let BakeError::DecoratorParseError { decorator, .. } = err {
assert_eq!(decorator, "pipelne");
}
}
#[test]
fn test_parse_decorator_uppercase_name() {
let result = parse_decorator("# @PIPELINE", 1);
assert!(result.is_err());
// Regex is case-sensitive, so it won't match @PIPELINE
assert!(result.is_err());
}
// =====================================================================
// Tests for parse_decorator - Line number tracking
// =====================================================================
#[test]
fn test_parse_decorator_line_number_preserved() {
let result = parse_decorator("# @pipeline", 42);
assert!(result.is_ok());
// Line number is preserved in error cases
let result = parse_decorator("# @pipeline(invalid)", 42);
assert!(result.is_err());
if let BakeError::DecoratorParseError { line_num, .. } = result.unwrap_err() {
assert_eq!(line_num, 42);
}
}
// =====================================================================
// Tests for parse_decorator - Edge cases
// =====================================================================
#[test]
fn test_parse_decorator_very_large_loop_count() {
let result = parse_decorator(&format!("# @loop({})", usize::MAX), 1);
assert!(result.is_ok());
assert_eq!(result.unwrap(), Some(Decorator::Loop(usize::MAX)));
}
#[test]
fn test_parse_decorator_very_large_timeout() {
let result = parse_decorator(&format!("# @timeout({})", u32::MAX), 1);
assert!(result.is_ok());
assert_eq!(result.unwrap(), Some(Decorator::Timeout(u32::MAX)));
}
#[test]
fn test_parse_decorator_if_with_special_chars() {
// Test condition with special characters that might break parsing
let result = parse_decorator(r#"# @if(x != "" && y > 0)"#, 1).unwrap();
assert_eq!(
result,
Some(Decorator::If(r#"x != "" && y > 0"#.to_string()))
);
}
#[test]
fn test_parse_decorator_pipe_with_underscores() {
let result = parse_decorator("# @pipe(step_1, step_2, step_3)", 1).unwrap();
assert_eq!(
result,
Some(Decorator::Pipe(vec![
"step_1".to_string(),
"step_2".to_string(),
"step_3".to_string()
]))
);
}
#[test]
fn test_parse_decorator_multiple_in_sequence() {
// Simulate multiple decorator lines
let line1 = parse_decorator("# @pipeline", 1).unwrap();
let line2 = parse_decorator("# @if(condition)", 2).unwrap();
let line3 = parse_decorator("# @timeout(60)", 3).unwrap();
assert_eq!(line1, Some(Decorator::Pipeline));
assert_eq!(line2, Some(Decorator::If("condition".to_string())));
assert_eq!(line3, Some(Decorator::Timeout(60)));
}
}
+467
View File
@@ -0,0 +1,467 @@
use super::decorator::{parse_decorator, Decorator};
use crate::bake::BakeError;
use lazy_static::lazy_static;
use regex::Regex;
use shlex::Shlex;
lazy_static! {
static ref FUNCTION_REGEX: Regex =
Regex::new(r"^\s*(function\s*)([a-zA-Z0-9_-]+)\s*(\(\))?").unwrap();
static ref FUNCTION_REGEX_WITHOUT_KEYWORD: Regex =
Regex::new(r"^\s*([a-zA-Z0-9_-]+)\s*(\(\))").unwrap();
static ref DECORATOR_HEADER_REGEX: Regex = Regex::new(r"\s*#\s+@").unwrap();
}
#[derive(Debug, Clone)]
pub struct Function {
pub name: String,
pub decorators: Vec<Decorator>,
pub body: String,
}
impl Function {
pub fn find_decorator<T>(&self, matcher: impl Fn(&Decorator) -> Option<T>) -> Option<T> {
self.decorators.iter().find_map(matcher)
}
}
#[derive(Debug)]
enum ParserState {
Normal,
AfterDecorator,
InFunction {
name: String,
decorators: Vec<Decorator>,
body: String,
brace_depth: i16,
},
}
#[derive(Debug)]
pub struct ParsedScript {
pub pipeline_functions: Vec<Function>,
pub remaining_code: String,
}
pub fn parse_script(text: &str) -> Result<ParsedScript, BakeError> {
let mut pipeline_functions = Vec::new();
let mut remaining_code = String::new();
let mut state = ParserState::Normal;
let mut pending_decorators: Vec<Decorator> = Vec::new();
for (line_number, line) in text.lines().enumerate() {
let line_number = line_number + 1;
match &mut state {
ParserState::Normal => {
if DECORATOR_HEADER_REGEX.find(line).is_some() {
if let Some(decorator) = parse_decorator(line, line_number)? {
pending_decorators.push(decorator);
}
state = ParserState::AfterDecorator;
continue;
}
if FUNCTION_REGEX.find(line).is_some()
|| FUNCTION_REGEX_WITHOUT_KEYWORD.find(line).is_some()
{
let tokens = Shlex::new(line).collect::<Vec<String>>();
let (name, _has_keyword) = if tokens[0] == "function" {
(FUNCTION_REGEX.captures(line).unwrap()[2].to_string(), true)
} else {
(
FUNCTION_REGEX_WITHOUT_KEYWORD.captures(line).unwrap()[1].to_string(),
false,
)
};
let has_pipeline = pending_decorators
.iter()
.any(|d| matches!(d, Decorator::Pipeline));
let brace_depth = tokens.iter().filter(|&token| token == "{").count() as i16
- tokens.iter().filter(|&token| token == "}").count() as i16;
if brace_depth != 0 {
state = ParserState::InFunction {
name,
decorators: pending_decorators.clone(),
body: line.to_string() + "\n",
brace_depth,
};
} else {
if has_pipeline {
pipeline_functions.push(Function {
name: name.clone(),
decorators: pending_decorators.clone(),
body: line.to_string() + "\n",
});
} else {
for dec in &pending_decorators {
remaining_code.push_str(&format!("# {}\n", dec));
}
remaining_code.push_str(line);
remaining_code.push('\n');
}
pending_decorators.clear();
state = ParserState::Normal;
}
} else {
remaining_code.push_str(line);
remaining_code.push('\n');
}
}
ParserState::AfterDecorator => {
if line.trim().is_empty() {
continue;
}
if DECORATOR_HEADER_REGEX.find(line).is_some() {
if let Some(decorator) = parse_decorator(line, line_number)? {
pending_decorators.push(decorator);
}
continue;
}
if line.trim().starts_with('#') {
continue;
}
if FUNCTION_REGEX.find(line).is_some()
|| FUNCTION_REGEX_WITHOUT_KEYWORD.find(line).is_some()
{
let tokens = Shlex::new(line).collect::<Vec<String>>();
let (name, _has_keyword) = if tokens[0] == "function" {
(FUNCTION_REGEX.captures(line).unwrap()[2].to_string(), true)
} else {
(
FUNCTION_REGEX_WITHOUT_KEYWORD.captures(line).unwrap()[1].to_string(),
false,
)
};
let has_pipeline = pending_decorators
.iter()
.any(|d| matches!(d, Decorator::Pipeline));
let brace_depth = tokens.iter().filter(|&token| token == "{").count() as i16
- tokens.iter().filter(|&token| token == "}").count() as i16;
if brace_depth != 0 {
state = ParserState::InFunction {
name,
decorators: pending_decorators.clone(),
body: line.to_string() + "\n",
brace_depth,
};
} else {
if has_pipeline {
pipeline_functions.push(Function {
name: name.clone(),
decorators: pending_decorators.clone(),
body: line.to_string() + "\n",
});
} else {
for dec in &pending_decorators {
remaining_code.push_str(&format!("# {}\n", dec));
}
remaining_code.push_str(line);
remaining_code.push('\n');
}
pending_decorators.clear();
state = ParserState::Normal;
}
continue;
}
eprintln!(
"Warning: Dangling decorator(s) at line {}. Treating as comments.",
line_number - 1
);
for dec in &pending_decorators {
remaining_code.push_str(&format!("# {}\n", dec));
}
pending_decorators.clear();
state = ParserState::Normal;
remaining_code.push_str(line);
remaining_code.push('\n');
}
ParserState::InFunction {
name,
decorators,
body,
brace_depth,
} => {
let has_pipeline = decorators.iter().any(|d| matches!(d, Decorator::Pipeline));
body.push_str(line);
body.push('\n');
let tokens = Shlex::new(line).collect::<Vec<String>>();
*brace_depth += tokens.iter().filter(|&token| token == "{").count() as i16;
*brace_depth -= tokens.iter().filter(|&token| token == "}").count() as i16;
if *brace_depth == 0 && !body.trim().is_empty() {
if has_pipeline {
pipeline_functions.push(Function {
name: name.clone(),
decorators: decorators.clone(),
body: body.clone(),
});
} else {
for dec in decorators {
remaining_code.push_str(&format!("# {}\n", dec));
}
remaining_code.push_str(body);
}
pending_decorators.clear();
state = ParserState::Normal;
}
}
}
}
if let ParserState::InFunction {
name, brace_depth, ..
} = state
{
return Err(BakeError::ScriptGenerationFailed {
script: "bake.sh".to_string(),
reason: format!(
"Unexpected EOF while parsing function '{}'. Unmatched braces (remaining: {})?",
name, brace_depth
),
});
}
if !pending_decorators.is_empty() {
let last_decorator = pending_decorators.last().unwrap();
return Err(BakeError::ScriptGenerationFailed {
script: "bake.sh".to_string(),
reason: format!(
"Dangling decorator {} before EOF. Missing function definition?",
last_decorator
),
});
}
Ok(ParsedScript {
pipeline_functions,
remaining_code,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_script() {
let script = "";
let result = parse_script(&script.to_string()).unwrap();
assert!(result.pipeline_functions.is_empty());
assert!(result.remaining_code.is_empty());
}
#[test]
fn test_only_comments() {
let script = "# comment\n# another";
let result = parse_script(&script.to_string()).unwrap();
assert!(result.pipeline_functions.is_empty());
assert_eq!(result.remaining_code.trim(), "# comment\n# another");
}
#[test]
fn test_function_with_parens_non_pipeline() {
let script = "func() { echo hi; }";
let result = parse_script(&script.to_string()).unwrap();
assert!(result.pipeline_functions.is_empty());
assert!(result.remaining_code.contains("func()"));
}
#[test]
fn test_pipeline_function() {
let script = "# @pipeline\nfunc() { echo hi; }";
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 1);
assert_eq!(result.pipeline_functions[0].name, "func");
assert!(result.remaining_code.is_empty());
}
#[test]
fn test_mixed_functions() {
let script = r#"#!/bin/bash
# comment
function helper() { echo "helper"; }
# @pipeline
prepare() { echo "prepare"; }
echo "Done."
"#;
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 1);
assert_eq!(result.pipeline_functions[0].name, "prepare");
assert!(result.remaining_code.contains("# comment"));
assert!(result.remaining_code.contains("helper()"));
assert!(result.remaining_code.contains("echo \"Done.\""));
}
#[test]
fn test_only_pipeline_functions_remaining_empty() {
let script = "# @pipeline\nfunc1() { echo 1; }\n# @pipeline\nfunc2() { echo 2; }";
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 2);
assert!(result.remaining_code.is_empty());
}
#[test]
fn test_user_example() {
let script = r#"#!/bin/bash
#Implicit set -euo pipefail
# function to be used by any pipeline stage
function get_custom_library() {
curl -LO https://archive.ppy.sh/$2/$1.tar.gz
}
# @pipeline
prepare_dotnet() {
curl -L https://dot.net/v1/dotnet-install.sh | bash
bake_info "Successfully installed dotnet"
}
# @pipeline
fetch_source() {
git clone https://github.com/ppy/osu.git --depth 1
}
echo "Done."
"#;
let result = parse_script(&script.to_string());
match result {
Ok(r) => {
assert_eq!(
r.pipeline_functions.len(),
2,
"Expected 2 pipeline functions, got {}",
r.pipeline_functions.len()
);
assert_eq!(r.pipeline_functions[0].name, "prepare_dotnet");
assert_eq!(r.pipeline_functions[1].name, "fetch_source");
}
Err(e) => {
panic!("Parse error: {:?}", e);
}
}
}
#[test]
fn test_blank_lines_between_decorator_and_function() {
let script = "# @pipeline\n\nfunc() { echo 1; }";
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 1);
assert_eq!(result.pipeline_functions[0].name, "func");
assert!(result.remaining_code.is_empty());
}
#[test]
fn test_multiple_blank_lines_between_decorator_and_function() {
let script = "# @pipeline\n\n\nfunc() { echo 1; }";
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 1);
assert_eq!(result.pipeline_functions[0].name, "func");
}
#[test]
fn test_comments_between_decorator_and_function() {
let script = "# @pipeline\n# comment\nfunc() { echo 1; }";
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 1);
assert_eq!(result.pipeline_functions[0].name, "func");
}
#[test]
fn test_multiple_comments_between_decorator_and_function() {
let script = "# @pipeline\n# comment 1\n# comment 2\nfunc() { echo 1; }";
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 1);
assert_eq!(result.pipeline_functions[0].name, "func");
}
#[test]
fn test_mixed_blank_lines_and_comments_between_decorator_and_function() {
let script = "# @pipeline\n# comment\n\n# another comment\n\nfunc() { echo 1; }";
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 1);
assert_eq!(result.pipeline_functions[0].name, "func");
}
#[test]
fn test_shell_statement_after_decorator_warns() {
let script = "# @pipeline\necho \"Done.\"";
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 0);
assert!(result.remaining_code.contains("# @pipeline"));
assert!(result.remaining_code.contains("echo \"Done.\""));
}
#[test]
fn test_nested_braces_in_string() {
let script = r#"# @pipeline
func() { echo "{"; }"#;
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 1);
assert_eq!(result.pipeline_functions[0].name, "func");
}
#[test]
fn test_nested_braces_in_double_string() {
let script = r#"# @pipeline
func() { echo "{}"; }"#;
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 1);
assert_eq!(result.pipeline_functions[0].name, "func");
}
#[test]
fn test_nested_braces_in_code() {
let script = r#"# @pipeline
func() {
if [ -z "${VAR}" ]; then
echo "empty"
fi
}"#;
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 1);
assert_eq!(result.pipeline_functions[0].name, "func");
}
#[test]
fn test_function_with_curl_braces_url() {
let script = r#"# @pipeline
func() {
curl -LO https://example.com/file.tar.gz
}"#;
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 1);
}
#[test]
fn test_function_with_json_like_content() {
let script = r#"# @pipeline
func() { echo '{"key": "value"}'; }"#;
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 1);
}
#[test]
fn test_decorator_with_blank_lines_then_shell_statement() {
let script = "# @pipeline\n\n\necho \"Done.\"";
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 0);
assert!(result.remaining_code.contains("# @pipeline"));
}
#[test]
fn test_multiple_decorators_then_function() {
let script = "# @pipeline\n# @fallible\n\n\nfunc() { echo 1; }";
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 1);
assert_eq!(result.pipeline_functions[0].decorators.len(), 2);
}
#[test]
fn test_multiple_decorators_then_shell_statement() {
let script = "# @pipeline\n# @fallible\necho \"Done.\"";
let result = parse_script(&script.to_string()).unwrap();
assert_eq!(result.pipeline_functions.len(), 0);
assert!(result.remaining_code.contains("# @pipeline"));
assert!(result.remaining_code.contains("# @fallible"));
}
}
+514
View File
@@ -0,0 +1,514 @@
use super::{decorator::Decorator, parser::Function};
use crate::error::BakeError;
use std::collections::HashMap;
use topological_sort::TopologicalSort;
pub fn sort_function(functions: Vec<Function>) -> Result<Vec<Function>, BakeError> {
let function_mapped = functions
.iter()
.map(|f| (f.name.as_str(), f))
.collect::<HashMap<&str, &Function>>();
let mut ts = TopologicalSort::<String>::new();
// Track last pipeline function for implicit @after
let mut last_pipeline: Option<String> = None;
for function in &functions {
ts.insert(function.name.clone());
let is_pipeline = function
.decorators
.iter()
.any(|d| matches!(d, Decorator::Pipeline));
let has_after = function
.decorators
.iter()
.any(|d| matches!(d, Decorator::After(_)));
// Add implicit @after for pipeline functions that don't have explicit @after
if is_pipeline {
if !has_after {
if let Some(ref last) = last_pipeline {
ts.add_dependency(last, function.name.clone());
}
}
last_pipeline = Some(function.name.clone());
}
for decorator in &function.decorators {
if let Decorator::After(dependency) = decorator {
if !function_mapped.contains_key(dependency.as_str()) {
log::error!(
"Unknown dependency of function {}: {}",
function.name,
dependency
);
return Err(BakeError::UnknownDependency(
function.name.clone(),
dependency.clone(),
));
}
ts.add_dependency(dependency, function.name.clone());
}
}
}
let mut functions_sorted = Vec::<Function>::new();
while let Some(name) = ts.pop() {
let function = function_mapped[name.as_str()];
functions_sorted.push(function.clone());
}
if !ts.is_empty() {
let remaining: Vec<String> = ts.collect();
return Err(BakeError::CircularDependency(remaining));
}
Ok(functions_sorted)
}
#[cfg(test)]
mod tests {
use super::*;
fn create_function(name: &str, decorators: Vec<Decorator>, body: &str) -> Function {
Function {
name: name.to_string(),
decorators,
body: body.to_string(),
}
}
#[test]
fn test_empty_functions() {
let functions = vec![];
let result = sort_function(functions).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_single_function_no_dependencies() {
let function = create_function("func1", vec![], "body1");
let functions = vec![function.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "func1");
}
#[test]
fn test_multiple_functions_no_dependencies() {
let function1 = create_function("func1", vec![], "body1");
let function2 = create_function("func2", vec![], "body2");
let function3 = create_function("func3", vec![], "body3");
let functions = vec![function1.clone(), function2.clone(), function3.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 3);
let names: Vec<String> = result.iter().map(|f| f.name.clone()).collect();
assert!(names.contains(&"func1".to_string()));
assert!(names.contains(&"func2".to_string()));
assert!(names.contains(&"func3".to_string()));
}
#[test]
fn test_simple_dependency() {
let function1 = create_function("func1", vec![], "body1");
let function2 = create_function(
"func2",
vec![Decorator::After("func1".to_string())],
"body2",
);
let functions = vec![function1.clone(), function2.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].name, "func1");
assert_eq!(result[1].name, "func2");
}
#[test]
fn test_multiple_dependencies() {
let function1 = create_function("func1", vec![], "body1");
let function2 = create_function(
"func2",
vec![Decorator::After("func1".to_string())],
"body2",
);
let function3 = create_function(
"func3",
vec![Decorator::After("func2".to_string())],
"body3",
);
let functions = vec![function1.clone(), function2.clone(), function3.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0].name, "func1");
assert_eq!(result[1].name, "func2");
assert_eq!(result[2].name, "func3");
}
#[test]
fn test_complex_dependencies() {
let function1 = create_function("func1", vec![], "body1");
let function2 = create_function(
"func2",
vec![
Decorator::After("func1".to_string()),
Decorator::After("func3".to_string()),
],
"body2",
);
let function3 = create_function(
"func3",
vec![Decorator::After("func1".to_string())],
"body3",
);
let function4 = create_function("func4", vec![], "body4");
let functions = vec![
function1.clone(),
function2.clone(),
function3.clone(),
function4.clone(),
];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 4);
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions.get("func1").unwrap() < positions.get("func2").unwrap());
assert!(positions.get("func1").unwrap() < positions.get("func3").unwrap());
assert!(positions.get("func3").unwrap() < positions.get("func2").unwrap());
}
#[test]
fn test_mixed_decorators() {
let function1 = create_function(
"func1",
vec![Decorator::Pipeline, Decorator::Timeout(1000)],
"body1",
);
let function2 = create_function(
"func2",
vec![
Decorator::After("func1".to_string()),
Decorator::Retry(3, 100),
Decorator::Fallible,
],
"body2",
);
let functions = vec![function1.clone(), function2.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].name, "func1");
assert_eq!(result[1].name, "func2");
}
#[test]
fn test_function_with_multiple_after_decorators() {
let function1 = create_function("func1", vec![], "body1");
let function2 = create_function("func2", vec![], "body2");
let function3 = create_function(
"func3",
vec![
Decorator::After("func1".to_string()),
Decorator::After("func2".to_string()),
],
"body3",
);
let functions = vec![function1.clone(), function2.clone(), function3.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 3);
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions.get("func1").unwrap() < positions.get("func3").unwrap());
assert!(positions.get("func2").unwrap() < positions.get("func3").unwrap());
}
#[test]
fn test_ignores_non_after_decorators() {
let function1 = create_function(
"func1",
vec![
Decorator::Pipeline,
Decorator::If("condition".to_string()),
Decorator::Fallible,
],
"body1",
);
let function2 = create_function(
"func2",
vec![
Decorator::Loop(5),
Decorator::Timeout(1000),
Decorator::Retry(3, 100),
Decorator::Export,
],
"body2",
);
let functions = vec![function1.clone(), function2.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 2);
let names: Vec<String> = result.iter().map(|f| f.name.clone()).collect();
assert!(names.contains(&"func1".to_string()));
assert!(names.contains(&"func2".to_string()));
}
#[test]
fn test_implicit_after_pipeline() {
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
let func2 = create_function("func2", vec![Decorator::Pipeline], "body2");
let func3 = create_function("func3", vec![Decorator::Pipeline], "body3");
let functions = vec![func1.clone(), func2.clone(), func3.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0].name, "func1");
assert_eq!(result[1].name, "func2");
assert_eq!(result[2].name, "func3");
}
#[test]
fn test_first_pipeline_no_implicit_after() {
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
let functions = vec![func1.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "func1");
}
#[test]
fn test_explicit_after_overrides_implicit() {
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
let func2 = create_function(
"func2",
vec![Decorator::Pipeline, Decorator::After("func1".to_string())],
"body2",
);
let func3 = create_function("func3", vec![Decorator::Pipeline], "body3");
let functions = vec![func1.clone(), func2.clone(), func3.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 3);
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions.get("func1").unwrap() < positions.get("func2").unwrap());
assert!(positions.get("func2").unwrap() < positions.get("func3").unwrap());
}
#[test]
fn test_mixed_pipeline_and_non_pipeline() {
let func1 = create_function("func1", vec![], "body1");
let func2 = create_function("func2", vec![Decorator::Pipeline], "body2");
let func3 = create_function("func3", vec![], "body3");
let func4 = create_function("func4", vec![Decorator::Pipeline], "body4");
let functions = vec![func1.clone(), func2.clone(), func3.clone(), func4.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 4);
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions.get("func2").unwrap() < positions.get("func4").unwrap());
}
#[test]
fn test_single_pipeline_works() {
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
let functions = vec![func1.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "func1");
}
#[test]
fn test_two_pipelines_implicit_chain() {
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
let func2 = create_function("func2", vec![Decorator::Pipeline], "body2");
let functions = vec![func1.clone(), func2.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 2);
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions.get("func1").unwrap() < positions.get("func2").unwrap());
}
#[test]
fn test_pipeline_explicit_after_skips_implicit() {
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
let func2 = create_function("func2", vec![Decorator::Pipeline], "body2");
let func3 = create_function(
"func3",
vec![Decorator::Pipeline, Decorator::After("func1".to_string())],
"body3",
);
let functions = vec![func1.clone(), func2.clone(), func3.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 3);
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions.get("func1").unwrap() < positions.get("func3").unwrap());
// TODO: Our design spec indicates order-based sorting, however toposort crate does not guarantee order of independent nodes. We should consider whether we want to enforce order-based sorting for pipelines, or if we are okay with any valid topological order.
// assert!(positions.get("func2").unwrap() < positions.get("func3").unwrap());
}
#[test]
fn test_circular_dependency_detected() {
let func1 = create_function(
"func1",
vec![Decorator::After("func2".to_string())],
"body1",
);
let func2 = create_function(
"func2",
vec![Decorator::After("func1".to_string())],
"body2",
);
let functions = vec![func1.clone(), func2.clone()];
let result = sort_function(functions);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
BakeError::CircularDependency(_)
));
}
#[test]
fn test_unknown_dependency_error() {
let func1 = create_function(
"func1",
vec![Decorator::After("nonexistent".to_string())],
"body1",
);
let functions = vec![func1.clone()];
let result = sort_function(functions);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
BakeError::UnknownDependency(_, _)
));
}
#[test]
fn test_pipeline_with_multiple_decorators() {
let func1 = create_function(
"func1",
vec![
Decorator::Pipeline,
Decorator::Timeout(60),
Decorator::Fallible,
],
"body1",
);
let func2 = create_function(
"func2",
vec![
Decorator::Pipeline,
Decorator::Retry(3, 5),
Decorator::After("func1".to_string()),
],
"body2",
);
let functions = vec![func1.clone(), func2.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 2);
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions.get("func1").unwrap() < positions.get("func2").unwrap());
}
#[test]
fn test_long_pipeline_chain() {
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
let func2 = create_function("func2", vec![Decorator::Pipeline], "body2");
let func3 = create_function("func3", vec![Decorator::Pipeline], "body3");
let func4 = create_function("func4", vec![Decorator::Pipeline], "body4");
let func5 = create_function("func5", vec![Decorator::Pipeline], "body5");
let functions = vec![
func1.clone(),
func2.clone(),
func3.clone(),
func4.clone(),
func5.clone(),
];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 5);
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions.get("func1").unwrap() < positions.get("func2").unwrap());
assert!(positions.get("func2").unwrap() < positions.get("func3").unwrap());
assert!(positions.get("func3").unwrap() < positions.get("func4").unwrap());
assert!(positions.get("func4").unwrap() < positions.get("func5").unwrap());
}
#[test]
fn test_non_pipeline_between_pipelines() {
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
let func2 = create_function("func2", vec![], "body2");
let func3 = create_function("func3", vec![Decorator::Pipeline], "body3");
let functions = vec![func1.clone(), func2.clone(), func3.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 3);
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions.get("func1").unwrap() < positions.get("func3").unwrap());
}
}
+14
View File
@@ -0,0 +1,14 @@
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";
+193
View File
@@ -0,0 +1,193 @@
use crate::cli::{Cli};
// use serde::Serialize;
use serde_json::Value;
// use std::path::PathBuf;
// use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::net::{UnixStream};
// use tokio::process::{Child, Command};
// use tokio::signal;
// use tokio::sync::RwLock;
// use tokio::time::{Duration, interval};
// #[derive(Debug, Clone, Serialize)]
// struct Heartbeat {
// pipeline_id: String,
// build_id: String,
// stage: String,
// timestamp: u64,
// status: String,
// subtasks: Vec<String>,
// }
pub async fn daemon(_cli: &Cli) {
todo!("Daemon should be refactored");
// let socket = PathBuf::from(settings.get_string("socket").unwrap());
// let agentsocket = settings.get_string("agentsocket").unwrap();
// let _ = std::fs::remove_file(&socket);
// let heartbeat = Arc::new(RwLock::new(Heartbeat {
// pipeline_id: cli.pipeline.clone(),
// build_id: cli.build_id.clone(),
// stage: String::new(),
// timestamp: 0,
// status: String::new(),
// subtasks: Vec::new(),
// }));
// // let heartbeat_clone = heartbeat.clone();
// let heartbeat_task = {
// let hb = heartbeat.clone();
// tokio::spawn(async move {
// let mut tick = interval(Duration::from_secs(1));
// loop {
// tick.tick().await;
// let mut hb = hb.write().await;
// hb.timestamp = std::time::SystemTime::now()
// .duration_since(std::time::UNIX_EPOCH)
// .unwrap()
// .as_millis() as u64;
// // 只输出到stdout(模拟未来发送给Agent
// println!("[HEARTBEAT] {}", serde_json::to_string(&*hb).unwrap());
// }
// })
// };
// let socket_task = {
// let socket = socket.clone();
// tokio::spawn(async move {
// log::info!("Creating Unix socket at {}", socket.display());
// let listener =
// UnixListener::bind(&socket).expect("Failed to bind Unix socket");
// loop {
// match listener.accept().await {
// Ok((stream, _)) => {
// tokio::spawn(handle_connection(stream));
// }
// Err(e) => {
// log::error!("Accept error: {}", e);
// break;
// }
// }
// }
// })
// };
// let mut tasks: Vec<TaskSpec> = Vec::new();
// let execution_task = {
// let hb = heartbeat.clone();
// tokio::spawn(async move {
// let mut hb = hb.write().await;
// let mut children: Vec<(String, Child)> = vec![];
// let executable = std::env::current_exe().unwrap();
// for (index, task) in tasks.iter().enumerate() {
// let mut command = task.command(&executable);
// command.stdout(std::process::Stdio::piped());
// command.stderr(std::process::Stdio::piped());
// let child = command
// .spawn()
// .expect(format!("Failed to spawn child {}", index).as_str());
// let pid = child.id().unwrap_or(0).to_string();
// children.push((pid.clone(), child));
// hb.subtasks.push(pid.clone());
// }
// for (pid, mut child) in children {
// let status = child.wait().await.expect("Failed to wait child");
// log::info!("Subtask {} exited with: {}", pid, status);
// hb.subtasks.retain(|p| p != &pid);
// }
// hb.stage = "Success".to_string();
// })
// };
// tokio::select! {
// _ = heartbeat_task => {
// log::error!("Heartbeat task died");
// }
// _ = execution_task => {
// log::info!("All subtasks completed");
// }
// _ = socket_task => {
// log::error!("Socket task died");
// }
// _ = signal::ctrl_c() => {
// log::info!("Shutting down...");
// }
// }
// for pid in heartbeat.read().await.subtasks.clone() {
// let _ = Command::new("kill").arg("-TERM").arg(&pid).status().await;
// }
// let _ = std::fs::remove_file(&socket);
}
async fn _handle_connection(stream: UnixStream) {
let peer_addr = match stream.peer_addr() {
Ok(addr) => format!("{:?}", addr),
Err(_) => "unknown".to_string(),
};
log::debug!("New connection from: {}", peer_addr);
let mut reader = BufReader::new(stream);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => {
log::debug!("Connection closed by peer: {}", peer_addr);
break;
}
Ok(_) => {
// 简单打印原始JSON(预留字段解析空间)
match serde_json::from_str::<Value>(&line) {
Ok(json) => {
// 只提取关键字段打印(字段可能变化)
let msgtype = json
.get("msgtype")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let name = json
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unnamed");
log::info!("[{}] Message from '{}': {}", msgtype, name, line.trim());
// TODO: 字段稳定后,可扩展为结构化打印
// match msgtype {
// "ResourceUsage" => print_resource(&json),
// "Log" => print_log(&json),
// _ => log::debug!("Unknown type: {}", msgtype),
// }
}
Err(e) => {
log::warn!(
"Invalid JSON from {}: {} - Error: {}",
peer_addr,
line.trim(),
e
);
}
}
}
Err(e) => {
log::error!("Failed to read from {}: {}", peer_addr, e);
break;
}
}
}
log::debug!("Connection handler for {} exiting", peer_addr);
}
+17
View File
@@ -0,0 +1,17 @@
//! 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,
};
+46
View File
@@ -0,0 +1,46 @@
# 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
+104
View File
@@ -0,0 +1,104 @@
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
pub struct CgroupManager {
path: PathBuf,
}
impl CgroupManager {
pub fn new(build_id: &str) -> io::Result<Self> {
let path = PathBuf::from(format!("/sys/fs/cgroup/ws-executor/{}", build_id));
fs::create_dir_all(&path)?;
Ok(Self { path })
}
pub fn set_memory_limit(&self, limit_bytes: u64) -> io::Result<()> {
let mem_max_path = self.path.join("memory.max");
let content = if limit_bytes == 0 {
"max".to_string()
} else {
limit_bytes.to_string()
};
fs::write(&mem_max_path, content)?;
let mem_swap_path = self.path.join("memory.swap.max");
fs::write(&mem_swap_path, "max")?;
Ok(())
}
pub fn set_cpu_weight(&self, weight: u64) -> io::Result<()> {
let cpu_weight_path = self.path.join("cpu.weight");
let weight = weight.clamp(1, 10000);
fs::write(&cpu_weight_path, weight.to_string())?;
Ok(())
}
pub fn set_cpu_max(&self, max_us: u64, period_us: u64) -> io::Result<()> {
let cpu_max_path = self.path.join("cpu.max");
let content = if max_us == 0 {
"max".to_string()
} else {
format!("{} {}", max_us, period_us)
};
fs::write(&cpu_max_path, content)?;
Ok(())
}
pub fn add_process(&self, pid: u32) -> io::Result<()> {
let cgroup_procs_path = self.path.join("cgroup.procs");
let mut file = fs::OpenOptions::new()
.append(true)
.open(&cgroup_procs_path)?;
writeln!(file, "{}", pid)?;
Ok(())
}
pub fn destroy(&self) -> io::Result<()> {
if self.path.exists() {
fs::remove_dir(&self.path)?;
}
Ok(())
}
pub fn path(&self) -> &PathBuf {
&self.path
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore]
fn test_cgroup_manager_new() {
let build_id = "test-build-123";
let manager = CgroupManager::new(build_id).unwrap();
assert!(manager.path().exists());
manager.destroy().unwrap();
}
#[test]
#[ignore]
fn test_set_memory_limit() {
let build_id = "test-mem-123";
let manager = CgroupManager::new(build_id).unwrap();
manager.set_memory_limit(1024 * 1024 * 1024).unwrap();
manager.destroy().unwrap();
}
#[test]
#[ignore]
fn test_set_cpu_weight() {
let build_id = "test-cpu-123";
let manager = CgroupManager::new(build_id).unwrap();
manager.set_cpu_weight(1024).unwrap();
manager.destroy().unwrap();
}
}
+655
View File
@@ -0,0 +1,655 @@
use chrono::Utc;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use tokio::sync::mpsc;
use tokio::time::timeout;
use super::pm::PackageManager;
use super::types::{
CgroupManager, EventSender, ExecutionContext, ExecutionError, ExecutionEvent, ExecutionResult,
ResourceLimits, DeltaExecutionContext, StreamType
};
impl super::types::Engine {
pub fn new() -> Self {
Self {
cgroup_manager: None,
}
}
pub fn with_cgroup(limits: ResourceLimits, build_id: &str) -> Result<Self, String> {
let cgroup_manager =
CgroupManager::new(build_id).map_err(|e| format!("Failed to create cgroup: {}", e))?;
if let Some(mem) = limits.memory_bytes {
cgroup_manager
.set_memory_limit(mem)
.map_err(|e| format!("Failed to set memory limit: {}", e))?;
}
if let Some(weight) = limits.cpu_weight {
cgroup_manager
.set_cpu_weight(weight)
.map_err(|e| format!("Failed to set cpu weight: {}", e))?;
}
if let Some(max) = limits.cpu_max_us {
cgroup_manager
.set_cpu_max(max, limits.cpu_period_us)
.map_err(|e| format!("Failed to set cpu max: {}", e))?;
}
Ok(Self {
cgroup_manager: Some(cgroup_manager),
})
}
fn apply_cgroup(&self, pid: u32) -> Result<(), ExecutionError> {
if let Some(ref cgroup) = self.cgroup_manager {
cgroup.add_process(pid).map_err(|e| {
ExecutionError::IoError(format!("Failed to add process to cgroup: {}", e))
})?;
}
Ok(())
}
pub async fn execute_script(
&self,
script_path: &PathBuf,
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> Result<ExecutionResult, ExecutionError> {
let abs_path = if script_path.is_absolute() {
script_path.clone()
} else {
ctx.working_dir.join(script_path)
};
if !abs_path.exists() {
return Err(ExecutionError::ScriptNotFound(abs_path))
}
let mut command = Command::new(ctx.shell.as_str());
command.arg("-c");
command.arg(abs_path);
self.execute(command, ctx, event_tx).await
}
pub async fn execute_command(
&self,
cmd: &str,
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> Result<ExecutionResult, ExecutionError> {
let mut command = Command::new(ctx.shell.as_str());
command.arg("-c");
command.arg(cmd);
self.execute(command, ctx, event_tx).await
}
pub async fn execute_command_with_delta(
&self,
cmd: &str,
ctx: &ExecutionContext,
event_tx: &EventSender,
delta_ctx: &DeltaExecutionContext
) -> Result<ExecutionResult,ExecutionError> {
let mut local_ctx = ctx.clone();
if let Some(working_dir) = &delta_ctx.working_dir {
log::debug!("Updating working directory to {}", working_dir.display());
local_ctx.working_dir = working_dir.clone();
}
if let Some(timeout) = &delta_ctx.timeout {
log::debug!("Updating timeout to {}s", timeout.as_secs_f64());
local_ctx.timeout = Some(*timeout);
}
for (key, value) in &delta_ctx.env_vars {
log::debug!("Updating envvar {} with {:?}", key, value);
match value {
Some(v) => local_ctx.env_vars.insert(key.clone(), v.clone()),
None => local_ctx.env_vars.remove(key),
};
}
self.execute_command(cmd, &local_ctx, event_tx).await
}
fn construct_command(&self, command: &mut Command, ctx: &ExecutionContext) {
command
.current_dir(&ctx.working_dir)
.envs(&ctx.env_vars)
.env("HBW_TASKID", &ctx.task_id)
.env("HBW_PIPELINE", &ctx.pipeline_name)
.env("HBW_USERNAME", &ctx.username)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
}
pub async fn execute(
&self,
command: Command,
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> Result<ExecutionResult, ExecutionError> {
let start_time = std::time::Instant::now();
if let Some(tx) = event_tx {
let _ = tx
.send(ExecutionEvent::TaskStarted {
task_id: ctx.task_id.clone(),
timestamp: Utc::now(),
})
.await;
}
let mut command = command;
self.construct_command(&mut command, ctx);
if ctx.dry_run {
log::info!("[DRY_RUN] {:?}", command);
return Ok(ExecutionResult {
..Default::default()
});
}
let mut child = command.spawn().map_err(|e| {
ExecutionError::InvalidCommand(format!("Failed to spawn command: {}", e))
})?;
let pid = child
.id()
.ok_or_else(|| ExecutionError::IoError("Failed to get PID".to_string()))?;
self.apply_cgroup(pid)?;
let task_id = ctx.task_id.clone();
let (stdout_tx, mut stdout_rx) = mpsc::channel::<String>(1024);
let (stderr_tx, mut stderr_rx) = mpsc::channel::<String>(1024);
let mut stdout_buf = String::new();
let mut stderr_buf = String::new();
let mut child_stdout = child.stdout.take();
let mut child_stderr = child.stderr.take();
let tx_clone = stdout_tx.clone();
let _task_id_clone = task_id.clone();
let stdout_handle = tokio::spawn(async move {
if let Some(mut stdout) = child_stdout.take() {
let mut reader = tokio::io::BufReader::new(&mut stdout);
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
let data = String::from_utf8_lossy(&buf[..n]).to_string();
if tx_clone.send(data).await.is_err() {
break;
}
}
Err(_) => break,
}
}
}
});
let tx_clone = stderr_tx.clone();
let stderr_handle = tokio::spawn(async move {
if let Some(mut stderr) = child_stderr.take() {
let mut reader = tokio::io::BufReader::new(&mut stderr);
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
let data = String::from_utf8_lossy(&buf[..n]).to_string();
if tx_clone.send(data).await.is_err() {
break;
}
}
Err(_) => break,
}
}
}
});
let status = if let Some(timeout_duration) = ctx.timeout {
match timeout(timeout_duration, child.wait()).await {
Ok(Ok(status)) => status,
Ok(Err(e)) => {
return Err(ExecutionError::IoError(format!(
"Failed to wait for process: {}",
e
)))
}
Err(_) => {
log::warn!(
"Task {} timed out after {:?}, killing process",
ctx.task_id,
timeout_duration
);
if let Some(tx) = event_tx {
let _ = tx
.send(ExecutionEvent::TaskFailed {
task_id: ctx.task_id.clone(),
error: "Timeout".to_string(),
})
.await;
}
let _ = child.kill().await;
drop(stdout_tx);
drop(stderr_tx);
let _ = stdout_handle.await;
let _ = stderr_handle.await;
while let Some(data) = stdout_rx.recv().await {
stdout_buf.push_str(&data);
}
while let Some(data) = stderr_rx.recv().await {
stderr_buf.push_str(&data);
}
return Err(ExecutionError::Timeout);
}
}
} else {
child.wait().await.map_err(|e| {
ExecutionError::IoError(format!("Failed to wait for process: {}", e))
})?
};
drop(stdout_tx);
drop(stderr_tx);
let _ = stdout_handle.await;
let _ = stderr_handle.await;
while let Some(data) = stdout_rx.recv().await {
stdout_buf.push_str(&data);
if let Some(tx) = event_tx {
let _ = tx.send(ExecutionEvent::OutputChunk {
task_id: task_id.clone(),
stream: StreamType::Stdout,
data: data.clone(),
}).await;
}
}
while let Some(data) = stderr_rx.recv().await {
stderr_buf.push_str(&data);
if let Some(tx) = event_tx {
let _ = tx.send(ExecutionEvent::OutputChunk {
task_id: task_id.clone(),
stream: StreamType::Stderr,
data: data.clone(),
}).await;
}
}
let exit_code = status.code().unwrap_or(-1);
let duration = start_time.elapsed();
let result = ExecutionResult {
task_id: ctx.task_id.clone(),
exit_code,
success: exit_code == 0,
duration,
stdout: stdout_buf,
stderr: stderr_buf,
resource_usage: None,
};
if let Some(tx) = event_tx {
let event = if result.success {
ExecutionEvent::TaskCompleted {
task_id: ctx.task_id.clone(),
result: result.clone(),
}
} else {
ExecutionEvent::TaskFailed {
task_id: ctx.task_id.clone(),
error: format!("Exit code: {}", exit_code),
}
};
let _ = tx.send(event).await;
}
if result.success {
Ok(result)
} else {
Err(ExecutionError::ExecutionFailed(format!(
"Task {} failed with exit code {}",
ctx.task_id, exit_code
)))
}
}
pub async fn install_dependency(
&self,
package: &[String],
pkgman: &dyn PackageManager,
ctx: &ExecutionContext,
event_tx: &EventSender
) -> Result<ExecutionResult, ExecutionError> {
let cmds = pkgman.install(package);
let mut combined_result = ExecutionResult {
task_id: ctx.task_id.clone(),
exit_code: 0,
success: true,
duration: std::time::Duration::default(),
stdout: String::new(),
stderr: String::new(),
resource_usage: None,
};
for cmd in cmds {
log::debug!("Installing dependency with command: {:?}", cmd);
let result = self.execute(cmd, ctx, event_tx).await?;
// TODO: Aggregate results properly instead of just taking the last one
combined_result.exit_code = result.exit_code;
combined_result.success &= result.success;
combined_result.duration += result.duration;
combined_result.stdout.push_str(&result.stdout);
combined_result.stderr.push_str(&result.stderr);
}
Ok(combined_result)
}
pub async fn cleanup(&self) -> Result<(), String> {
if let Some(ref cgroup) = self.cgroup_manager {
cgroup
.destroy()
.map_err(|e| format!("Failed to destroy cgroup: {}", e))?;
}
Ok(())
}
}
impl Default for super::types::Engine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[derive(Clone, Debug)]
struct CapturedCall {
program: String,
args: Vec<String>,
env_vars: std::collections::HashMap<String, String>,
working_dir: PathBuf,
}
#[derive(Clone)]
struct MockExecutor {
calls: Arc<Mutex<VecDeque<CapturedCall>>>,
}
impl MockExecutor {
fn new() -> Self {
Self {
calls: Arc::new(Mutex::new(VecDeque::new())),
}
}
fn capture(
&self,
program: String,
args: Vec<String>,
env_vars: std::collections::HashMap<String, String>,
working_dir: PathBuf,
) {
self.calls.lock().unwrap().push_back(CapturedCall {
program,
args,
env_vars,
working_dir,
});
}
fn pop(&self) -> Option<CapturedCall> {
self.calls.lock().unwrap().pop_front()
}
}
struct TestExecutor {
mock: MockExecutor,
}
impl TestExecutor {
fn new() -> Self {
Self {
mock: MockExecutor::new(),
}
}
async fn execute_command(
&self,
cmd: String,
ctx: &ExecutionContext,
_event_tx: EventSender,
) -> Result<ExecutionResult, ExecutionError> {
let mut env_vars = ctx.env_vars.clone();
env_vars.insert("WS_TASKID".to_string(), ctx.task_id.clone());
env_vars.insert("WS_PIPELINE".to_string(), ctx.pipeline_name.clone());
env_vars.insert("WS_USERNAME".to_string(), ctx.username.clone());
self.mock.capture(
ctx.shell.clone(),
vec!["-c".to_string(), cmd.clone()],
env_vars,
ctx.working_dir.clone(),
);
Ok(ExecutionResult {
task_id: ctx.task_id.clone(),
exit_code: 0,
success: true,
duration: Duration::from_millis(10),
stdout: String::new(),
stderr: String::new(),
resource_usage: None,
})
}
async fn execute_script(
&self,
script_path: &PathBuf,
ctx: &ExecutionContext,
_event_tx: EventSender,
) -> Result<ExecutionResult, ExecutionError> {
let abs_path = if script_path.is_absolute() {
script_path.clone()
} else {
ctx.working_dir.join(script_path)
};
self.mock.capture(
ctx.shell.clone(),
vec!["-c".to_string(), abs_path.to_string_lossy().into_owned()],
ctx.env_vars.clone(),
ctx.working_dir.clone(),
);
Ok(ExecutionResult {
task_id: ctx.task_id.clone(),
exit_code: 0,
success: true,
duration: Duration::from_millis(10),
stdout: String::new(),
stderr: String::new(),
resource_usage: None,
})
}
}
fn create_test_context() -> ExecutionContext {
ExecutionContext {
task_id: "test-task-1".to_string(),
pipeline_name: "test-pipeline".to_string(),
username: "testuser".to_string(),
working_dir: PathBuf::from("/tmp"),
env_vars: std::collections::HashMap::new(),
timeout: Some(Duration::from_secs(30)),
cgroup_path: None,
shell: "bash".to_string(),
dry_run: false,
privileged: false,
standalone: false,
}
}
#[tokio::test]
async fn test_execute_command_captures_args() {
let executor = TestExecutor::new();
let ctx = create_test_context();
let result = executor
.execute_command("echo hello".to_string(), &ctx, None)
.await;
assert!(result.is_ok());
let call = executor.mock.pop();
assert!(call.is_some());
let call = call.unwrap();
assert_eq!(call.program, "bash");
assert!(call.args.contains(&"-c".to_string()));
assert!(call.args.contains(&"echo hello".to_string()));
}
#[tokio::test]
async fn test_execute_script_captures_path() {
let executor = TestExecutor::new();
let ctx = create_test_context();
let script_path = PathBuf::from("/tmp/test_script.sh");
let result = executor.execute_script(&script_path, &ctx, None).await;
assert!(result.is_ok());
let call = executor.mock.pop().unwrap();
assert!(call.args.contains(&"-c".to_string()));
assert!(call.args.iter().any(|a| a.contains("test_script.sh")));
}
#[tokio::test]
async fn test_execute_command_with_env_vars() {
let executor = TestExecutor::new();
let mut ctx = create_test_context();
ctx.env_vars
.insert("TEST_VAR".to_string(), "test_value".to_string());
executor
.execute_command("echo $TEST_VAR".to_string(), &ctx, None)
.await
.unwrap();
let call = executor.mock.pop().unwrap();
assert_eq!(
call.env_vars.get("TEST_VAR"),
Some(&"test_value".to_string())
);
}
#[tokio::test]
async fn test_execute_includes_ws_environment() {
let executor = TestExecutor::new();
let ctx = create_test_context();
executor
.execute_command("ls".to_string(), &ctx, None)
.await
.unwrap();
let call = executor.mock.pop().unwrap();
assert!(call.env_vars.contains_key("WS_TASKID"));
assert!(call.env_vars.contains_key("WS_PIPELINE"));
assert!(call.env_vars.contains_key("WS_USERNAME"));
}
#[tokio::test]
async fn test_execute_command_preserves_working_dir() {
let executor = TestExecutor::new();
let mut ctx = create_test_context();
ctx.working_dir = PathBuf::from("/custom/path");
executor
.execute_command("pwd".to_string(), &ctx, None)
.await
.unwrap();
let call = executor.mock.pop().unwrap();
assert_eq!(call.working_dir, PathBuf::from("/custom/path"));
}
#[tokio::test]
async fn test_local_executor_new_creates_default() {
let executor = super::super::types::Engine::new();
assert!(executor.cgroup_manager.is_none());
}
#[tokio::test]
async fn test_execution_context_default() {
let ctx = ExecutionContext::default();
assert!(ctx.task_id.is_empty());
assert_eq!(ctx.shell, "bash");
}
#[tokio::test]
async fn test_execution_result_success() {
let result = ExecutionResult {
task_id: "test".to_string(),
exit_code: 0,
success: true,
duration: Duration::from_secs(1),
stdout: "output".to_string(),
stderr: "".to_string(),
resource_usage: None,
};
assert!(result.success);
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_execution_result_failure() {
let result = ExecutionResult {
task_id: "test".to_string(),
exit_code: 1,
success: false,
duration: Duration::from_secs(1),
stdout: "".to_string(),
stderr: "error".to_string(),
resource_usage: None,
};
assert!(!result.success);
assert_eq!(result.exit_code, 1);
}
#[tokio::test]
async fn test_execution_error_variants() {
let _err1 = ExecutionError::InvalidCommand("test".to_string());
let _err2 = ExecutionError::ScriptNotFound(PathBuf::from("/nonexistent"));
let _err3 = ExecutionError::PackageManagerNotFound;
let _err4 = ExecutionError::Timeout;
}
#[tokio::test]
async fn test_resource_limits_default() {
let limits = ResourceLimits::default();
assert!(limits.memory_bytes.is_none());
assert!(limits.cpu_weight.is_none());
assert_eq!(limits.cpu_period_us, 0);
}
}
+55
View File
@@ -0,0 +1,55 @@
use os_info::Info;
use serde_yaml::Value;
use tokio::process::Command;
// TODO: apt
// mod apt;
mod pacman;
pub trait PackageManager {
/// Name of package manager
fn name(&self) -> &'static str;
/// Adopt for a distro
fn adopt(&self, distro: &Info) -> bool;
/// Binary name
fn binary(&self) -> &'static str;
/// The command of updating
fn update(&self) -> Vec<Command>;
/// The command of installing packages
fn install(&self, package: &[String]) -> Vec<Command>;
// Check whether a package is installed
// Currently not planned
// Package manager should handle installed packages themselves
// just as --needed in pacman
// Also the PM module should generate command, not execute them
// fn is_installed(&self, _package: &str) -> Option<bool> {unimplemented!()}
/// Change major mirror of package manager
fn change_mirror(&self, repo: &str, osinfo: Option<&Info>) -> Vec<Command>;
/// add a custom repository
fn add_repository(&self, repo: &Value, osinfo: Option<&Info>) -> Vec<Command>;
}
pub fn all_managers() -> Vec<Box<dyn PackageManager>> {
vec![
// Box::new(apt::Apt),
Box::new(pacman::Pacman),
// Box::new(dnf::Dnf),
]
}
pub fn detect(distro: &os_info::Info) -> Option<Box<dyn PackageManager>> {
all_managers().into_iter().find(|pm| pm.adopt(distro))
}
pub fn select(manager: &str) -> Option<Box<dyn PackageManager>> {
// TODO: allow specify PM
// log::warn!("Manually select package manager is not recommended.");
// log::warn!("Use at your own risk.");
all_managers().into_iter().find(|pm| pm.name() == manager)
}
+115
View File
@@ -0,0 +1,115 @@
use crate::engine::pm::PackageManager;
use os_info::{Info, Version};
use serde_yaml::Value;
use tokio::process::Command;
#[derive(Clone)]
pub struct Apt;
#[derive(Clone)]
struct AptRepositories {
url: String,
key: Option<String>,
}
impl PackageManager for Apt {
fn name(&self) -> &'static str {
"apt"
}
fn adopt(&self, distro: &Info) -> bool {
return false;
// todo!();
}
fn binary(&self) -> &'static str {
"apt"
}
fn update(&self) -> Command {
let mut cmd = Command::new("apt");
cmd.arg("update");
cmd
}
fn install(&self, package: &[String]) -> Command {
let mut cmd = Command::new("apt");
cmd.arg("install");
cmd.arg("-y"); // 自动确认
cmd.args(package);
cmd
}
fn change_mirror(&self, repo: &str, osinfo: Option<&Info>) -> Command {
todo!();
}
fn add_repository(&self, repo: &Value, osinfo: Option<&Info>) -> Vec<Command> {
todo!();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_update_command_contents() {
let cmd = Apt.update();
let cmd = cmd.as_std();
assert_eq!(cmd.get_program().to_string_lossy(), "apt");
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(args, vec!["update"]);
}
#[test]
fn test_install_command_contents() {
let cmd = Apt.install(&["curl".to_string()]);
let cmd = cmd.as_std();
assert_eq!(cmd.get_program().to_string_lossy(), "apt");
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(args, vec!["install", "-y", "curl"]);
}
}
/// 辅助函数:从 os_info 中提取 Codename
fn extract_codename(info: Option<&Info>) -> Option<String> {
let info = info?;
// 1. 直接获取 codename (os_info 较新版本支持)
if let Some(codename) = info.codename() {
if !codename.is_empty() {
return Some(codename.to_string());
}
}
// 2. 尝试从 version 中推断 (针对某些把 codename 放在 Custom 里的情况)
match info.version() {
Version::Custom(v) => {
// 有些系统可能把 codename 放在这里,虽然不标准
if !v.chars().next().map(|c| c.is_numeric()).unwrap_or(true) {
return Some(v.clone());
}
}
Version::Semantic(major, minor, _) => {
// 对于 Ubuntu/Debian,如果没有 codename,有时可以用版本号兜底 (如 22.04)
// 但 apt 源通常更喜欢 codename。如果实在没有,返回版本号字符串作为最后手段
return Some(format!("{}.{}", major, minor));
}
_ => {}
}
// 3. 如果 os_info 没提供,且是常见发行版,可以尝试根据 Type 猜测?
// 不推荐硬编码猜测,因为版本太多。
// 最好返回 None,让调用者决定是报错还是让用户手动指定。
None
}
+619
View File
@@ -0,0 +1,619 @@
use super::PackageManager;
use os_info::{Info, Type};
use serde::Deserialize;
use serde_yaml::Value;
use tokio::process::Command;
#[derive(Clone)]
pub struct Pacman;
#[derive(Deserialize)]
struct PacmanRepo {
name: String,
server: String,
cacheserver: Option<String>,
siglevel: Option<SigLevel>,
keyid: Option<String>,
keypackage: Option<String>,
}
struct SigLevel {
raw: String,
}
impl SigLevel {
pub fn new(raw: String) -> Self {
for token in raw.split_whitespace() {
if !VALID_SIGLEVEL_TOKENS.contains(&token) {
log::warn!("Invalid siglevel token: {}", token);
}
}
Self { raw }
}
}
impl std::fmt::Display for SigLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.raw)
}
}
impl<'de> Deserialize<'de> for SigLevel {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(SigLevel::new(s))
}
}
const VALID_SIGLEVEL_TOKENS: &[&str] = &[
// Token
"Never",
"Optional",
"Required",
"TrustedOnly",
"TrustAll",
// Package prefix
"PackageNever",
"PackageOptional",
"PackageRequired",
"PackageTrustedOnly",
"PackageTrustAll",
// Database prefix
"DatabaseNever",
"DatabaseOptional",
"DatabaseRequired",
"DatabaseTrustedOnly",
"DatabaseTrustAll",
];
impl PackageManager for Pacman {
fn name(&self) -> &'static str {
"pacman"
}
fn adopt(&self, distro: &Info) -> bool {
matches!(
distro.os_type(),
Type::Arch
| Type::Manjaro
| Type::EndeavourOS
| Type::Garuda
| Type::CachyOS
| Type::Artix
| Type::Mabox
| Type::Nobara
| Type::InstantOS
)
}
fn binary(&self) -> &'static str {
"pacman"
}
fn update(&self) -> Vec<Command> {
let mut cmd = Command::new("pacman");
cmd.arg("-Sy");
vec![cmd]
}
fn install(&self, package: &[String]) -> Vec<Command> {
let mut cmd = Command::new("pacman");
cmd.arg("-S");
cmd.arg("--noconfirm");
cmd.arg("--needed");
cmd.args(package);
vec![cmd]
}
fn change_mirror(&self, repo: &str, _osinfo: Option<&Info>) -> Vec<Command> {
let mirrorlist_path = "/etc/pacman.d/mirrorlist";
let content = format!(
"# Generated by WSExecutor-PM\n\
# Date: $(date)\n\
# Original content overwritten.\n\
\n\
Server = {}\n",
repo
);
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(format!(
"cat > {} << 'PACMAN_EOF'\n{}\nPACMAN_EOF",
mirrorlist_path, content
));
let mut keyring_cmd = Command::new("sh");
keyring_cmd.arg("-c").arg("pacman-key --init");
vec![cmd, keyring_cmd]
}
fn add_repository(&self, repo: &Value, _osinfo: Option<&Info>) -> Vec<Command> {
let repos: Vec<PacmanRepo> = match serde_yaml::from_value(repo.clone()) {
Ok(r) => r,
Err(e) => {
log::error!("Failed to parse pacman repository: {}", e);
return Vec::new();
}
};
let pacman_conf = "/etc/pacman.conf";
let mut content = String::with_capacity(512);
content.push_str("# Modified by HBW baker\n");
let mut cmds = vec![];
for repo in &repos {
// Install keyring
if repo.keyid.is_some() && repo.keypackage.is_some() {
log::error!(
"Both keyid and keypackage are set for repository {}",
repo.name
);
log::warn!("Ignoring...");
continue;
}
if let Some(keyid) = &repo.keyid {
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(format!("pacman-key --recv-keys {}", keyid));
cmds.push(cmd);
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(format!("pacman-key --lsign-key {}", keyid));
cmds.push(cmd);
} else if let Some(keypackage) = &repo.keypackage {
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(format!("pacman -Sy {} --needed --noconfirm", keypackage));
cmds.push(cmd);
}
// Configure repository
let mut block = format!("[{}]\n", &repo.name);
block.push_str(&format!("Server = {}\n", &repo.server));
if let Some(cacheserver) = &repo.cacheserver {
block.push_str(&format!("CacheServer = {}\n", cacheserver));
}
if let Some(siglevel) = &repo.siglevel {
block.push_str(&format!("SigLevel = {}\n", siglevel));
}
content.push_str(&block);
}
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(format!(
"cat >> {} << 'PACMAN_EOF'\n{}\nPACMAN_EOF",
pacman_conf, content
));
cmds.insert(0, cmd);
cmds
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_name() {
let pacman = Pacman;
assert_eq!(pacman.name(), "pacman");
}
#[test]
fn test_binary() {
let pacman = Pacman;
assert_eq!(pacman.binary(), "pacman");
}
#[test]
fn test_adopt_true_for_arch_based_distros() {
let pacman = Pacman;
let arch_distros = [
Type::Arch,
Type::Manjaro,
Type::EndeavourOS,
Type::Garuda,
Type::CachyOS,
Type::Artix,
Type::Mabox,
Type::Nobara,
Type::InstantOS,
];
for distro in arch_distros {
let info = Info::with_type(distro);
assert!(
pacman.adopt(&info),
"Pacman should adopt distro: {:?}",
distro
);
}
}
#[test]
fn test_adopt_false_for_non_arch_distros() {
let pacman = Pacman;
let non_arch_distros = [
Type::Ubuntu,
Type::Debian,
Type::Fedora,
Type::CentOS,
Type::RedHatEnterprise,
Type::openSUSE,
Type::Gentoo,
Type::FreeBSD,
Type::Macos,
Type::Windows,
];
for distro in non_arch_distros {
let info = Info::with_type(distro);
assert!(
!pacman.adopt(&info),
"Pacman should NOT adopt distro: {:?}",
distro
);
}
}
#[test]
fn test_update_command_contents() {
let cmds = Pacman.update();
let cmd = cmds[0].as_std();
assert_eq!(cmd.get_program().to_string_lossy(), "pacman");
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(args, vec!["-Sy"]);
}
#[test]
fn test_install_command_contents() {
let cmds = Pacman.install(&["curl".to_string(), "wget".to_string()]);
let cmd = cmds[0].as_std();
assert_eq!(cmd.get_program().to_string_lossy(), "pacman");
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(args, vec!["-S", "--noconfirm", "--needed", "curl", "wget"]);
}
#[test]
fn test_install_single_package() {
let cmds = Pacman.install(&["package".to_string()]);
let cmd = cmds[0].as_std();
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(args, vec!["-S", "--noconfirm", "--needed", "package"]);
}
#[test]
fn test_siglevel_valid_tokens() {
let siglevel = SigLevel::new("Never".to_string());
assert_eq!(siglevel.to_string(), "Never");
let siglevel = SigLevel::new("Optional Required".to_string());
assert_eq!(siglevel.to_string(), "Optional Required");
let siglevel = SigLevel::new("PackageOptional DatabaseRequired".to_string());
assert_eq!(siglevel.to_string(), "PackageOptional DatabaseRequired");
}
#[test]
fn test_siglevel_with_invalid_tokens() {
// Invalid tokens are logged but still accepted
let siglevel = SigLevel::new("Never InvalidToken".to_string());
assert_eq!(siglevel.to_string(), "Never InvalidToken");
}
#[test]
fn test_siglevel_deserialize() {
let yaml = "Never";
let siglevel: SigLevel = serde_yaml::from_str(yaml).unwrap();
assert_eq!(siglevel.to_string(), "Never");
let yaml = "PackageOptional DatabaseRequired";
let siglevel: SigLevel = serde_yaml::from_str(yaml).unwrap();
assert_eq!(siglevel.to_string(), "PackageOptional DatabaseRequired");
}
#[test]
fn test_siglevel_all_valid_tokens() {
let all_tokens = VALID_SIGLEVEL_TOKENS.join(" ");
let siglevel = SigLevel::new(all_tokens.clone());
assert_eq!(siglevel.to_string(), all_tokens);
}
#[test]
fn test_change_mirror_command() {
let cmds = Pacman.change_mirror("https://mirror.example.com/archlinux", None);
let cmd = cmds[0].as_std();
assert_eq!(cmd.get_program().to_string_lossy(), "sh");
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let full_arg = args.join(" ");
assert!(full_arg.contains("cat > /etc/pacman.d/mirrorlist"));
assert!(full_arg.contains("https://mirror.example.com/archlinux"));
}
#[test]
fn test_add_repository_single_repo() {
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
serde_yaml::Value::Mapping(
[
(
serde_yaml::Value::String("name".to_string()),
serde_yaml::Value::String("customrepo".to_string()),
),
(
serde_yaml::Value::String("server".to_string()),
serde_yaml::Value::String("https://repo.example.com".to_string()),
),
]
.into_iter()
.collect(),
),
]))
.unwrap();
let cmds = Pacman.add_repository(&repo_yaml, None);
// Should have at least one command (the one to append to pacman.conf)
assert!(!cmds.is_empty());
let last_cmd = cmds.last().unwrap().as_std();
let args: Vec<String> = last_cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let full_arg = args.join(" ");
assert!(full_arg.contains("cat >> /etc/pacman.conf"));
assert!(full_arg.contains("[customrepo]"));
assert!(full_arg.contains("https://repo.example.com"));
}
#[test]
fn test_add_repository_with_siglevel() {
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
serde_yaml::Value::Mapping(
[
(
serde_yaml::Value::String("name".to_string()),
serde_yaml::Value::String("customrepo".to_string()),
),
(
serde_yaml::Value::String("server".to_string()),
serde_yaml::Value::String("https://repo.example.com".to_string()),
),
(
serde_yaml::Value::String("siglevel".to_string()),
serde_yaml::Value::String("PackageOptional".to_string()),
),
]
.into_iter()
.collect(),
),
]))
.unwrap();
let cmds = Pacman.add_repository(&repo_yaml, None);
let last_cmd = cmds.last().unwrap().as_std();
let args: Vec<String> = last_cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let full_arg = args.join(" ");
assert!(full_arg.contains("SigLevel = PackageOptional"));
}
#[test]
fn test_add_repository_with_keyid() {
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
serde_yaml::Value::Mapping(
[
(
serde_yaml::Value::String("name".to_string()),
serde_yaml::Value::String("customrepo".to_string()),
),
(
serde_yaml::Value::String("server".to_string()),
serde_yaml::Value::String("https://repo.example.com".to_string()),
),
(
serde_yaml::Value::String("keyid".to_string()),
serde_yaml::Value::String("ABCDEF123456".to_string()),
),
]
.into_iter()
.collect(),
),
]))
.unwrap();
let cmds = Pacman.add_repository(&repo_yaml, None);
// Should have 3 commands: recv-keys, lsign-key, and append to conf
assert_eq!(cmds.len(), 3);
let recv_cmd = cmds[1].as_std();
let recv_args: Vec<String> = recv_cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(recv_args
.join(" ")
.contains("pacman-key --recv-keys ABCDEF123456"));
let lsign_cmd = cmds[2].as_std();
let lsign_args: Vec<String> = lsign_cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(lsign_args
.join(" ")
.contains("pacman-key --lsign-key ABCDEF123456"));
}
#[test]
fn test_add_repository_with_keypackage() {
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
serde_yaml::Value::Mapping(
[
(
serde_yaml::Value::String("name".to_string()),
serde_yaml::Value::String("customrepo".to_string()),
),
(
serde_yaml::Value::String("server".to_string()),
serde_yaml::Value::String("https://repo.example.com".to_string()),
),
(
serde_yaml::Value::String("keypackage".to_string()),
serde_yaml::Value::String("gnupg".to_string()),
),
]
.into_iter()
.collect(),
),
]))
.unwrap();
let cmds = Pacman.add_repository(&repo_yaml, None);
// Should have 2 commands: install keypackage and append to conf
assert_eq!(cmds.len(), 2);
let install_cmd = cmds[1].as_std();
let install_args: Vec<String> = install_cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(install_args
.join(" ")
.contains("pacman -Sy gnupg --needed --noconfirm"));
}
#[test]
fn test_add_repository_with_cacheserver() {
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
serde_yaml::Value::Mapping(
[
(
serde_yaml::Value::String("name".to_string()),
serde_yaml::Value::String("customrepo".to_string()),
),
(
serde_yaml::Value::String("server".to_string()),
serde_yaml::Value::String("https://repo.example.com".to_string()),
),
(
serde_yaml::Value::String("cacheserver".to_string()),
serde_yaml::Value::String("https://cache.example.com".to_string()),
),
]
.into_iter()
.collect(),
),
]))
.unwrap();
let cmds = Pacman.add_repository(&repo_yaml, None);
let last_cmd = cmds.last().unwrap().as_std();
let args: Vec<String> = last_cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let full_arg = args.join(" ");
assert!(full_arg.contains("CacheServer = https://cache.example.com"));
}
#[test]
fn test_add_repository_multiple_repos() {
let repos_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
serde_yaml::Value::Mapping(
[
(
serde_yaml::Value::String("name".to_string()),
serde_yaml::Value::String("repo1".to_string()),
),
(
serde_yaml::Value::String("server".to_string()),
serde_yaml::Value::String("https://repo1.example.com".to_string()),
),
]
.into_iter()
.collect(),
),
serde_yaml::Value::Mapping(
[
(
serde_yaml::Value::String("name".to_string()),
serde_yaml::Value::String("repo2".to_string()),
),
(
serde_yaml::Value::String("server".to_string()),
serde_yaml::Value::String("https://repo2.example.com".to_string()),
),
]
.into_iter()
.collect(),
),
]))
.unwrap();
let cmds = Pacman.add_repository(&repos_yaml, None);
let last_cmd = cmds.last().unwrap().as_std();
let args: Vec<String> = last_cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let full_arg = args.join(" ");
assert!(full_arg.contains("[repo1]"));
assert!(full_arg.contains("[repo2]"));
}
#[test]
fn test_add_repository_both_keyid_and_keypackage_logs_error() {
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
serde_yaml::Value::Mapping(
[
(
serde_yaml::Value::String("name".to_string()),
serde_yaml::Value::String("customrepo".to_string()),
),
(
serde_yaml::Value::String("server".to_string()),
serde_yaml::Value::String("https://repo.example.com".to_string()),
),
(
serde_yaml::Value::String("keyid".to_string()),
serde_yaml::Value::String("ABCDEF123456".to_string()),
),
(
serde_yaml::Value::String("keypackage".to_string()),
serde_yaml::Value::String("gnupg".to_string()),
),
]
.into_iter()
.collect(),
),
]))
.unwrap();
let cmds = Pacman.add_repository(&repo_yaml, None);
// Should have no key-related commands when both are set (error case)
assert!(cmds.is_empty() || cmds.len() == 1); // Only the conf append cmd if any
}
#[test]
fn test_add_repository_invalid_yaml_returns_empty() {
let invalid_yaml = serde_yaml::Value::String("invalid: [yaml".to_string());
let cmds = Pacman.add_repository(&invalid_yaml, None);
assert!(cmds.is_empty());
}
}
+46
View File
@@ -0,0 +1,46 @@
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)
}
@@ -0,0 +1,99 @@
use std::collections::HashMap;
type PmName = &'static str;
type PkgName = &'static str;
fn pkg(packages: &[(PmName, PkgName)]) -> Vec<(PmName, PkgName)> {
packages.to_vec()
}
lazy_static::lazy_static! {
static ref MAPPING: HashMap<PkgName, Vec<(PmName, PkgName)>> = {
let mut m = HashMap::new();
m.insert("rustup", pkg(&[
("pacman", "rustup"),
("apt", "rustup"),
("dnf", "rustup"),
]));
m.insert("cargo", pkg(&[
("pacman", "cargo"),
("apt", "cargo"),
("dnf", "cargo"),
]));
m.insert("rustc", pkg(&[
("pacman", "rustc"),
("apt", "rustc"),
("dnf", "rustc"),
]));
m.insert("go", pkg(&[
("pacman", "go"),
("apt", "golang-go"),
("dnf", "golang"),
]));
m.insert("gofmt", pkg(&[
("pacman", "go"),
("apt", "golang-go"),
("dnf", "golang"),
]));
m.insert("nodejs", pkg(&[
("pacman", "nodejs"),
("apt", "nodejs"),
("dnf", "nodejs"),
]));
m.insert("npm", pkg(&[
("pacman", "npm"),
("apt", "npm"),
("dnf", "npm"),
]));
m.insert("yarn", pkg(&[
("pacman", "yarn"),
("apt", "yarn"),
("dnf", "yarn"),
]));
m.insert("pnpm", pkg(&[
("pacman", "pnpm"),
("apt", "pnpm"),
("dnf", "pnpm"),
]));
m.insert("python", pkg(&[
("pacman", "python"),
("apt", "python3"),
("dnf", "python3"),
]));
m.insert("pip", pkg(&[
("pacman", "python-pip"),
("apt", "python3-pip"),
("dnf", "python3-pip"),
]));
m
};
}
pub async fn resolve(
packages: &[String],
target_pm: &str,
) -> Vec<String> {
let mut result = Vec::new();
for pkg in packages {
if let Some(mappings) = MAPPING.get(pkg.as_str()) {
for (pm, name) in mappings {
if *pm == target_pm {
result.push(name.to_string());
}
}
}
}
result
}
+122
View File
@@ -0,0 +1,122 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use tokio::sync::mpsc;
pub use super::cgroups::CgroupManager;
pub use crate::error::{ExecutionError, EXITCODE_EXECUTION_ERROR, EXITCODE_IO_ERROR};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourceUsage {
pub cpu_time_secs: f64,
pub max_memory_kb: u64,
pub io_read_kb: u64,
pub io_write_kb: u64,
}
#[derive(Debug, Clone)]
pub struct ExecutionContext {
pub task_id: String,
pub pipeline_name: String,
pub username: String,
pub working_dir: PathBuf,
pub env_vars: HashMap<String, String>,
pub timeout: Option<Duration>,
pub cgroup_path: Option<PathBuf>,
pub shell: String,
pub dry_run: bool,
pub privileged: bool,
pub standalone: bool,
}
#[derive(Debug, Clone)]
pub struct DeltaExecutionContext {
pub working_dir: Option<PathBuf>,
pub env_vars: HashMap<String, Option<String>>,
pub timeout: Option<Duration>,
}
impl Default for ExecutionContext {
fn default() -> Self {
Self {
task_id: String::new(),
pipeline_name: String::new(),
username: String::new(),
working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
env_vars: HashMap::new(),
timeout: None,
cgroup_path: None,
shell: String::from("bash"),
dry_run: false,
privileged: false,
standalone: false,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ResourceLimits {
pub memory_bytes: Option<u64>,
pub cpu_weight: Option<u64>,
pub cpu_max_us: Option<u64>,
pub cpu_period_us: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ExecutionResult {
pub task_id: String,
pub exit_code: i32,
pub success: bool,
pub duration: Duration,
pub stdout: String,
pub stderr: String,
pub resource_usage: Option<ResourceUsage>,
}
impl Default for ExecutionResult {
fn default() -> Self {
Self {
task_id: String::new(),
exit_code: 0,
success: true,
duration: Duration::default(),
stdout: String::new(),
stderr: String::new(),
resource_usage: None,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub enum StreamType {
Stdout,
Stderr,
}
#[derive(Debug, Clone, Serialize)]
pub enum ExecutionEvent {
TaskStarted {
task_id: String,
timestamp: chrono::DateTime<chrono::Utc>,
},
OutputChunk {
task_id: String,
stream: StreamType,
data: String,
},
TaskCompleted {
task_id: String,
result: ExecutionResult,
},
TaskFailed {
task_id: String,
error: String,
},
}
pub type EventSender = Option<mpsc::Sender<ExecutionEvent>>;
pub struct Engine {
pub cgroup_manager: Option<CgroupManager>,
}
+44
View File
@@ -0,0 +1,44 @@
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)
}
+61
View File
@@ -0,0 +1,61 @@
use std::path::PathBuf;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::Engine;
use crate::engine::types::DeltaExecutionContext;
use crate::engine::upm::UPMSysDeps;
use crate::error::DependencyError;
use crate::types::command::CustomCommand;
use super::UserPackageManager;
#[derive(Clone)]
pub struct Custom;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
struct CustomConfig(Vec<CustomCommand>);
#[async_trait]
impl UserPackageManager for Custom {
fn name(&self) -> &'static str {
"custom"
}
fn system_dependency(&self, _config: &serde_yaml::Value) -> super::UPMSysDeps {
UPMSysDeps::new(vec![], true)
}
async fn main(
&self,
config: &serde_yaml::Value,
ctx: &crate::ExecutionContext,
event_tx: &crate::EventSender,
) -> Result<(), DependencyError> {
let custom_config: CustomConfig = match serde_yaml::from_value(config.clone()) {
Ok(r) => r,
Err(e) => {
log::error!("Unable to parse user.custom: {}", e);
return Err(DependencyError::ParseError());
}
};
let engine = Engine::new();
for (index, cmd) in custom_config.0.iter().enumerate() {
let deltactx = DeltaExecutionContext {
working_dir: cmd.working_dir.as_ref().map(PathBuf::from),
env_vars: cmd.environment.clone().unwrap_or_default(),
timeout: Some(std::time::Duration::from_millis(cmd.timeout_ms)),
};
log::info!("Executing custom command #{}: {}.", index, cmd.name);
let result = engine
.execute_command_with_delta(&cmd.command, ctx, event_tx, &deltactx)
.await;
if let Err(e) = result {
log::error!("Custom command {} failed: {:?}", index, e);
return Err(DependencyError::CustomFailed(e));
}
};
Ok(())
}
}
+164
View File
@@ -0,0 +1,164 @@
use os_info::Info;
use std::path::PathBuf;
use thiserror::Error;
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 PrebakeError {
pub 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 ExecutionError {
pub 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,
}
}
}
+116
View File
@@ -0,0 +1,116 @@
pub mod config;
pub mod error;
pub mod event;
pub mod plugin;
pub mod stage;
pub mod template;
use crate::constant::FINALIZE_STANDALONE_PLUGIN_PATH;
use crate::constant::STANDALONE_STATUS_DIR;
use std::path::PathBuf;
use crate::finalize::plugin::fetch::{self, FetchArgument};
use crate::cli::Cli;
use crate::engine::EventSender;
use crate::types::buildstatus::{read_build_status, BuildStatus, StageResult};
use crate::types::buildstatus::{StageInfo, StagePhase};
use chrono::Utc;
// use crate::finalize::error::PluginError;
pub use config::*;
pub use error::FinalizeError;
use std::path::Path;
pub fn parse(path: &Path) -> Result<FinalizeConfig, FinalizeError> {
let content = std::fs::read_to_string(path).map_err(FinalizeError::IoError)?;
let config: FinalizeConfig = serde_yaml::from_str(&content)?;
Ok(config)
}
pub async fn finalize(
finalize_path: &Path,
cli: &Cli,
ctx: &mut crate::ExecutionContext,
event_tx: EventSender,
) -> Result<(), FinalizeError> {
let mut finalize = parse(finalize_path)?;
let validate_result = finalize.validate();
if let Err(errors) = validate_result {
for error in &errors {
log::error!("Error: {}", error);
}
return Err(FinalizeError::ValidateError(errors));
};
if ctx.standalone {
// In standalone mode, finalize should download plugin by itself
log::debug!("Downloading {} plugins", finalize.plugin.len());
let plugin_path = PathBuf::from(FINALIZE_STANDALONE_PLUGIN_PATH);
// if plugin_path.exists() {
// std::fs::remove_dir_all(&plugin_path).map_err(FinalizeError::IoError)?;
// }
for (name, data) in finalize.plugin.iter_mut() {
log::info!("Downloading plugin {}", name);
let (source, config) = data.iter_mut().next().unwrap_or_else(|| {
panic!(
"Internal Error: Plugin {} has no source configured, this should have been caught by validation!",
name
)
});
let fetch_argument = FetchArgument {
checksum: config.checksum.clone(),
shallow: config.shallow,
};
fetch::fetch_plugin(&source, &name, &plugin_path, fetch_argument).await?;
config.runtime.fspath = Some(plugin_path.join(name));
}
}
// Register Plugins
log::info!("Registering {} plugins", finalize.plugin.len());
let registered_plugins = plugin::register(finalize.plugin, None)?;
// Early hook
let earlyhook_result = stage::hook::hook(
finalize.hooks.as_ref().and_then(|h| h.early.as_ref()),
ctx,
event_tx.clone(),
&registered_plugins,
)
.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();
// Prepare artifacts
// TODO: artifact stage implementation
// Late hook
stage::hook::hook(
finalize.hooks.as_ref().and_then(|h| h.late.as_ref()),
ctx,
event_tx.clone(),
&registered_plugins,
)
.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(())
}
+56
View File
@@ -0,0 +1,56 @@
# workshop-baker/src/finalize
**Generated:** 2026-04-22
**Commit:** 7b3b71a
Artifact packaging, distribution, deployment, and notifications.
## STRUCTURE
```
finalize/
├── config.rs # FinalizeConfig YAML (artifacts, deploy, notify)
├── stage.rs # FinalizeStage enum
├── stage/hook.rs # Post-build hooks
├── event.rs # Event propagation
├── template.rs # Variable substitution {{VAR}}
├── plugin.rs # Plugin trait + registry
├── plugin/
│ ├── metadata.rs # Plugin manifest parsing
│ ├── dylib.rs # Dynamic library plugins
│ ├── rhai.rs # Rhai scripting (todo!())
│ ├── shell.rs # Shell command plugins
│ └── internal/ # Built-in plugins
│ ├── mail/ # Email notifications (SMTP)
│ ├── webhook.rs # HTTP webhooks
│ ├── satori.rs # Satori integration
│ └── insitenotify.rs # In-site notifications
│ └── fetch/ # Artifact fetching
│ ├── git.rs # Git repository fetch
│ ├── http.rs # HTTP download
│ ├── extract.rs # Archive extraction
│ └── checksum.rs# SHA256 verification (SHA1 deprecated)
```
## WHERE TO LOOK
| Task | Location |
|------|----------|
| Config loading | `config.rs` - FinalizeConfig |
| Email notify | `plugin/internal/mail/` - SMTP templates |
| Webhooks | `plugin/internal/webhook.rs` |
| Artifact fetch | `plugin/fetch/` - git/http/extract |
| Checksums | `plugin/fetch/checksum.rs:13` - SHA1 deprecated |
## CONVENTIONS
- **Plugin System**: Dynamic + internal plugins via Plugin trait
- **Template Syntax**: `{{VARIABLE}}` handlebars-style
- **Stages**: Hook → Fetch → Build → Publish → Notify → Cleanup
- **Fetch**: Supports git, http, with extraction and checksum verify
## ANTI-PATTERNS
1. **SHA1 deprecated**`checksum.rs:13` warns SHA1 is deprecated
2. **Unimplemented**`rhai.rs:17` - "todo!()" for Rhai scripting
3. **TODO markers** — Multiple TODOs in config.rs (lines 305-306)
+451
View File
@@ -0,0 +1,451 @@
//! Finalize stage configuration types
//!
//! 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::time::deserialize_duration_ms;
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use serde_yaml::Value;
use std::collections::HashMap;
use std::path::PathBuf;
/// Version requirement for finalize.yml schema compatibility
pub const VERSION_REQUIREMENT: &str = "^0.0.1";
pub type RawPluginMap = HashMap<String, HashMap<String, PluginConfig>>;
/// Root configuration for finalize stage
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct FinalizeConfig {
/// Schema version for compatibility checking
pub version: String,
/// Plugin definitions
/// Map of logical plugin name -> plugin source URI -> plugin config
#[serde(default)]
pub plugin: RawPluginMap,
/// Notification configuration (templates + priority groups)
#[serde(default)]
pub notification: NotificationConfig,
/// Artifact definitions
#[serde(default)]
pub artifact: Vec<ArtifactDef>,
/// Cleanup configuration
#[serde(default)]
pub cleanup: CleanupConfig,
/// Hooks configuration
#[serde(default)]
pub hooks: Option<FinalizeHooks>,
}
/// Plugin configuration
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct PluginConfig {
/// Whether plugin failure should not fail the build.
/// Same as @fallible decorator
#[serde(default)]
pub fallible: bool,
/// Number of retries on failure (0 = no retry, -1 = infinite retries)
#[serde(default)]
pub retry: i8,
/// Timeout for plugin execution in milliseconds (0 = no timeout)
#[serde(default)]
pub timeout_ms: u64,
/// Whether the plugin should be resolved after bake stage
#[serde(default)]
pub late_binding: bool,
/// (Git) Enable shallow clone
#[serde(default)]
pub shallow: Option<bool>,
/// (Http) Checksum of the plugin package
#[serde(default)]
pub checksum: Option<String>,
/// Baker runtime configuration
#[serde(skip)]
pub runtime: PluginRuntimeConfig,
/// Plugin-specific configuration parameters.
/// These are passed to the plugin via according method.
#[serde(flatten)]
pub config: Value,
}
#[derive(Debug, Clone, Default)]
pub struct PluginRuntimeConfig {
/// Error description if plugin is unavailable at runtime.
/// None means available
pub error: Option<String>,
/// Path of plugin directory in the environment
pub fspath: Option<PathBuf>,
/// manifest.yml
pub manifest: Option<Value>,
}
/// Notification method configuration
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct NotificationMethod {
/// Whether notification failure should not trigger next priority group
#[serde(default)]
pub fallible: bool,
/// Payload schema: "default" (plugin-defined) or "custom" (requires on_* templates)
#[serde(default)]
pub schema: Option<String>,
/// Content template for build success notification
#[serde(default)]
pub on_success: Option<String>,
/// Content template for build failure notification
#[serde(default)]
pub on_failure: Option<String>,
/// Stage-triggered notification configuration
#[serde(default)]
pub on_finish_of: Option<OnFinishOf>,
/// Notification-specific configuration (url, token, to, from, etc.)
#[serde(flatten)]
pub config: Value,
}
/// Notification configuration container
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct NotificationConfig {
/// Template definitions for notification content
#[serde(default)]
pub templates: HashMap<String, HashMap<String, PluginConfig>>,
/// Priority groups for notification delivery
#[serde(flatten)]
pub groups: HashMap<u32, HashMap<String, NotificationMethod>>,
}
/// Stage-triggered notification configuration
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct OnFinishOf {
/// Stage name to trigger on
/// Format: "prebake.ready", "bake.build", "finalize.artifact", etc.
pub stage: String,
/// Content template for stage completion notification
pub content: String,
}
/// Artifact definition
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ArtifactDef {
/// Artifact ID (optional, auto-generated as MD5(PUID + PATH) if not specified)
#[serde(default)]
pub id: Option<String>,
/// Artifact path (supports glob patterns, empty for non-filesystem artifacts like Docker images)
#[serde(default)]
pub path: String,
/// Retention duration in milliseconds
/// Formats: "7d", "168h", "604800" (seconds), 604800 (number=seconds)
/// 0 = permanent retention
#[serde(
default = "default_retention_ms",
deserialize_with = "deserialize_duration_ms"
)]
pub retention_ms: u64,
/// Compression method: "zstd", "gzip", "none", "dir"
#[serde(default = "default_compression")]
pub compression: CompressionMethod,
/// Trigger conditions: "success", "failure", "always" (WIP: failure, always)
#[serde(default = "default_on_conditions")]
pub on: Vec<ArtifactCondition>,
/// Publishing targets
#[serde(default)]
pub publish: HashMap<String, PublishTarget>,
}
impl Default for ArtifactDef {
fn default() -> Self {
Self {
id: None,
path: String::new(),
retention_ms: default_retention_ms(),
compression: default_compression(),
on: default_on_conditions(),
publish: HashMap::new(),
}
}
}
/// Compression method for artifacts
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(rename_all = "lowercase")]
pub enum CompressionMethod {
#[default]
Zstd,
Gzip,
None,
Dir,
}
/// Artifact trigger condition
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum ArtifactCondition {
Success,
Failure,
Always,
}
/// Publish target configuration
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct PublishTarget {
/// Whether publish failure should not fail the build
#[serde(default)]
pub fallible: bool,
/// Conditional execution (WIP)
/// Format: "{{ env.BRANCH }} == 'main'"
#[serde(rename = "if", default)]
pub condition: Option<String>,
/// Plugin-specific configuration (image, bucket, etc.)
#[serde(flatten)]
pub config: Value,
}
/// Cleanup configuration
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct CleanupConfig {
/// Cleanup policy: "auto", "manual", "on_failure" (WIP: manual, on_failure)
#[serde(default = "default_cleanup_policy")]
pub policy: CleanupPolicy,
}
/// Cleanup policy
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(rename_all = "lowercase")]
pub enum CleanupPolicy {
#[default]
Auto,
Manual,
OnFailure,
}
/// Hooks configuration for finalize stage
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct FinalizeHooks {
/// Hooks executed before artifact collection
#[serde(default)]
pub early: Option<Vec<CustomCommand>>,
/// Hooks executed after all operations complete
#[serde(default)]
pub late: Option<Vec<CustomCommand>>,
}
// Default value functions
fn default_retention_ms() -> u64 {
7 * 24 * 3600 * 1000
}
fn default_compression() -> CompressionMethod {
CompressionMethod::Zstd
}
fn default_on_conditions() -> Vec<ArtifactCondition> {
vec![ArtifactCondition::Success]
}
fn default_cleanup_policy() -> CleanupPolicy {
CleanupPolicy::Auto
}
impl FinalizeConfig {
/// Validate the finalize configuration
pub fn validate(&self) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
// Validate version
let version_requirement =
VersionReq::parse(VERSION_REQUIREMENT).expect("Invalid version requirement");
let version = &self.version;
let version = match version.matches('.').count() {
0 => format!("{}.0.0", version),
1 => format!("{}.0", version),
_ => version.clone(),
};
match Version::parse(&version) {
Ok(version) if version_requirement.matches(&version) => {}
Ok(version) => errors.push(format!(
"Version {} does not satisfy requirement {}",
version, VERSION_REQUIREMENT
)),
Err(e) => errors.push(format!("Invalid version format: {}", e)),
}
// Validate plugin definitions: each plugin must have exactly one URL
for (plugin_name, urls) in &self.plugin {
match urls.len() {
0 => errors.push(format!("Plugin '{}' has no URL defined", plugin_name)),
1 => {}
n => errors.push(format!(
"Plugin '{}' has {} URL definitions, expected exactly 1",
plugin_name, n
)),
}
}
// TODO: Schema-based validation for plugin configs
// TODO: Validate that publish targets reference existing plugins
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_hooks_full() {
let yaml = r#"
version: "0.0.1"
hooks:
early:
- name: "test-early"
command: "echo early"
timeout: 10
late:
- name: "test-late"
command: "plugin:test-plugin"
working_dir: "/tmp"
"#;
let config: FinalizeConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.hooks.is_some());
let hooks = config.hooks.unwrap();
let early = hooks.early.as_ref().unwrap();
let late = hooks.late.as_ref().unwrap();
assert_eq!(early.len(), 1);
assert_eq!(late.len(), 1);
assert_eq!(early[0].command, "echo early");
assert_eq!(late[0].command, "plugin:test-plugin");
assert_eq!(late[0].working_dir, Some("/tmp".to_string()));
}
#[test]
fn test_deserialize_hooks_missing() {
let yaml = r#"
version: "0.0.1"
"#;
let config: FinalizeConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.hooks.is_none());
}
#[test]
fn test_deserialize_hooks_timeout_float() {
let yaml = r#"
version: "0.0.1"
hooks:
early:
- name: "float-timeout"
command: "echo ok"
timeout: 10.5
"#;
let config: FinalizeConfig = serde_yaml::from_str(yaml).unwrap();
let hooks = config.hooks.unwrap();
let early = hooks.early.unwrap();
assert_eq!(early[0].timeout_ms, 10500);
}
#[test]
fn test_deserialize_hooks_timeout_string() {
let yaml = r#"
version: "0.0.1"
hooks:
early:
- name: "string-timeout"
command: "echo ok"
timeout: "30s"
"#;
let config: FinalizeConfig = serde_yaml::from_str(yaml).unwrap();
let hooks = config.hooks.unwrap();
let early = hooks.early.unwrap();
assert_eq!(early[0].timeout_ms, 30000);
}
#[test]
fn test_validate_plugin_zero_urls() {
let config: FinalizeConfig = serde_yaml::from_str(
r#"
version: "0.0.1"
plugin:
show_file_list: {}
"#,
)
.unwrap();
let result = config.validate();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.contains("show_file_list") && e.contains("no URL")));
}
#[test]
fn test_validate_plugin_one_url_ok() {
let config: FinalizeConfig = serde_yaml::from_str(
r#"
version: "0.0.1"
plugin:
show_file_list:
"https://example.com/plugin.tar.gz":
fallible: false
"#,
)
.unwrap();
let result = config.validate();
assert!(result.is_ok());
}
#[test]
fn test_validate_plugin_multiple_urls() {
let config: FinalizeConfig = serde_yaml::from_str(
r#"
version: "0.0.1"
plugin:
show_file_list:
"https://example.com/plugin1.tar.gz":
fallible: false
"https://example.com/plugin2.tar.gz":
fallible: false
"#,
)
.unwrap();
let result = config.validate();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.contains("show_file_list") && e.contains("2 URL definitions")));
}
}
+80
View File
@@ -0,0 +1,80 @@
use std::path::PathBuf;
use thiserror::Error;
use crate::{ExecutionError, error::{
EXITCODE_EXECUTION_ERROR, EXITCODE_GENERAL_ERROR, EXITCODE_IO_ERROR, EXITCODE_PARSE_ERROR,
}};
#[derive(Error, Debug)]
pub enum FinalizeError {
#[error("Failed to parse finalize.yml: {0}")]
YamlParseError(#[from] serde_yaml::Error),
#[error("Failed to validate finalize.yml: {0:?}")]
ValidateError(Vec<String>),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Artifact not found: {0}")]
ArtifactNotFound(PathBuf),
#[error("Plugin error: {0}")]
PluginError(#[from] PluginError),
#[error("Plugin execution failed: {0}")]
PluginExecutionFailed(String),
#[error("Notification failed: {0}")]
NotificationFailed(String),
#[error("Template error: {0}")]
TemplateError(#[from] minijinja::Error),
#[error("Execution error: {0}")]
ExecutionError(#[from] ExecutionError),
}
impl FinalizeError {
pub fn exit_code(&self) -> i32 {
match self {
FinalizeError::YamlParseError(_) => EXITCODE_PARSE_ERROR,
FinalizeError::ValidateError(_) => EXITCODE_PARSE_ERROR,
FinalizeError::IoError(_) => EXITCODE_IO_ERROR,
FinalizeError::ArtifactNotFound(_) => EXITCODE_GENERAL_ERROR,
FinalizeError::PluginError(_) => EXITCODE_GENERAL_ERROR,
FinalizeError::PluginExecutionFailed(_) => EXITCODE_EXECUTION_ERROR,
FinalizeError::NotificationFailed(_) => EXITCODE_GENERAL_ERROR,
FinalizeError::TemplateError(_) => EXITCODE_PARSE_ERROR,
FinalizeError::ExecutionError(_) => EXITCODE_EXECUTION_ERROR,
}
}
}
#[derive(Error, Debug)]
pub enum PluginError {
#[error("Unknown plugin: {0}")]
UnknownPlugin(String),
#[error("Error: {0}")]
GeneralError(String),
#[error("Plugin {name} is not available: {reason}")]
PluginUnavailable{
name: String,
reason: String,
},
#[error("Plugin execution failed: {0}")]
ExecutionError(#[from] ExecutionError),
#[error("Failed to fetch plugin({name}:{url}): {reason}")]
FetchError{
name: String,
url: String,
reason: String,
},
#[error("Plugin manifest {0}/manifest.yml not found.")]
ManifestNotFound(PathBuf),
}
+36
View File
@@ -0,0 +1,36 @@
use crate::engine::{ExecutionEvent, StreamType};
pub async fn event_receiver(
mut rx: tokio::sync::mpsc::Receiver<ExecutionEvent>,
pipeline_name: String,
build_id: String,
) {
while let Some(event) = rx.recv().await {
match event {
ExecutionEvent::TaskStarted { task_id, timestamp } => {
log::debug!(
"[{}] [{}] [{}] Task started: {}",
pipeline_name, build_id, timestamp, task_id
);
}
ExecutionEvent::OutputChunk { task_id, stream, data } => {
match stream {
StreamType::Stdout => print!("[{}] [{}] [stdout] {}", pipeline_name, task_id, data),
StreamType::Stderr => eprint!("[{}] [{}] [stderr] {}", pipeline_name, task_id, data),
}
}
ExecutionEvent::TaskCompleted { task_id, result } => {
log::debug!(
"[{}] [{}] Task completed: {} (exit_code={}, duration={:?})",
pipeline_name, build_id, task_id, result.exit_code, result.duration
);
}
ExecutionEvent::TaskFailed { task_id, error } => {
log::error!(
"[{}] [{}] Task failed: {} (error={})",
pipeline_name, build_id, task_id, error
);
}
}
}
}
+300
View File
@@ -0,0 +1,300 @@
use crate::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};
use async_trait::async_trait;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
mod dylib;
pub mod fetch;
mod internal;
mod metadata;
mod rhai;
mod shell;
#[derive(Clone)]
pub struct FinalizePlugin {
metadata: PluginMetadata,
argument: serde_yaml::Value,
pub entry: Arc<dyn AsyncPluginFn>,
}
impl FinalizePlugin {
pub fn new(entry: Box<dyn AsyncPluginFn>) -> Self {
Self {
metadata: PluginMetadata::default(),
argument: serde_yaml::Value::Mapping(Default::default()),
entry: Arc::from(entry),
}
}
pub fn with_metadata(mut self, metadata: PluginMetadata) -> Self {
self.metadata = metadata;
self
}
pub fn with_argument(mut self, argument: serde_yaml::Value) -> Self {
self.argument = argument;
self
}
pub async fn call(
&self,
runtime_argument: serde_yaml::Value,
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> PluginResult {
// shallow merge argument
let _argument = match (&self.argument, runtime_argument) {
(serde_yaml::Value::Mapping(base), serde_yaml::Value::Mapping(runtime)) => {
let mut merged = base.clone();
for (k, v) in runtime {
merged.insert(k.clone(), v.clone());
}
serde_yaml::Value::Mapping(merged)
}
_ => self.argument.clone(), // If not both are mappings, just use the original argument
};
self.entry
.call(&self.metadata, _argument, ctx, event_tx)
.await
}
}
pub type PluginMap = HashMap<String, FinalizePlugin>;
type PluginResult = Result<(), PluginError>;
#[async_trait]
pub trait AsyncPluginFn: Send + Sync {
async fn call(
&self,
metadata: &PluginMetadata,
argument: serde_yaml::Value,
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> PluginResult;
}
/// Reads and parses a plugin's manifest file.
///
/// # Arguments
///
/// * `path` - Optional filesystem path to the plugin directory
/// * `name` - Name of the plugin (used for error reporting)
///
/// # Returns
///
/// Returns parsed [`PluginMetadata`] on success, or a [`PluginError`] if the manifest
/// cannot be read or parsed.
fn read_manifest(path: Option<PathBuf>, name: &str) -> Result<PluginMetadata, PluginError> {
let path = path.ok_or_else(|| PluginError::PluginUnavailable {
name: name.to_string(),
reason: "Plugin not present in filesystem.".to_string(),
})?;
// Extract content from manifest.yml or manifest.yaml
let manifest_content = 'load: {
for ext in FINALIZE_PLUGIN_MANIFEST_EXTENSION {
match load_manifest(&path, name, ext) {
Ok(content) => break 'load content,
Err(PluginError::ManifestNotFound(_)) => {}
Err(e) => return Err(e),
}
}
return Err(PluginError::ManifestNotFound(path));
};
log::debug!("[plugin:{}] Parsing manifest content", name);
let mut metadata: PluginMetadata =
serde_yaml::from_str(manifest_content.as_str()).map_err(|e| {
PluginError::PluginUnavailable {
name: name.to_string(),
reason: format!("Failed to parse plugin manifest: {}", e),
}
})?;
metadata.fspath = path;
Ok(metadata)
}
fn load_manifest(path: &Path, name: &str, suffix: &str) -> Result<String, PluginError> {
let manifest_path = path.join(format!("{}{}", FINALIZE_PLUGIN_MANIFEST, suffix));
log::debug!(
"[plugin:{}] Reading manifest from {}",
name,
manifest_path.display()
);
if !manifest_path.exists() {
return Err(PluginError::ManifestNotFound(manifest_path));
}
std::fs::read_to_string(&manifest_path).map_err(|e| PluginError::PluginUnavailable {
name: name.to_string(),
reason: format!(
"Failed to read plugin manifest from {}: {}",
manifest_path.display(),
e.to_string()
),
})
}
/// Determines the plugin type based on the manifest type or file extension.
///
/// The function first returns `original` if it's not [`PluginType::Unknown`].
/// Otherwise, it infers the type from the entrypoint file extension:
/// - `.sh` / `.bash` → [`PluginType::Shell`]
/// - `.rhai` → [`PluginType::Rhai`]
/// - `.so` / `.dll` / `.dylib` → [`PluginType::Dylib`]
///
/// # Arguments
///
/// * `original` - Plugin type as specified in the manifest
/// * `entrypoint` - Path or filename of the plugin entrypoint
///
/// # Returns
///
/// The inferred [`PluginType`].
fn get_plugin_type(original: PluginType, entrypoint: &str) -> PluginType {
if original != PluginType::Unknown {
return original;
}
let _suffix = entrypoint.rsplit('.').next().unwrap_or("");
match _suffix {
"sh" | "bash" => PluginType::Shell,
"rhai" => PluginType::Rhai,
"so" | "dll" | "dylib" => PluginType::Dylib,
_ => PluginType::Unknown,
}
}
/// Creates a plugin entry function instance based on the plugin type.
///
/// # Arguments
///
/// * `plugin_type` - The type of plugin to instantiate
/// * `name` - Name of the plugin (used for error reporting)
///
/// # Returns
///
/// Returns a boxed [`AsyncPluginFn`] on success, or a [`PluginError`] if the plugin type
/// is [`PluginType::Unknown`] and cannot be inferred.
fn get_entry(plugin_type: PluginType, name: &str) -> Result<Box<dyn AsyncPluginFn>, PluginError> {
match plugin_type {
PluginType::Shell => Ok(Box::new(shell::Shell)),
PluginType::Rhai => Ok(Box::new(rhai::Rhai)),
PluginType::Dylib => Ok(Box::new(dylib::Dylib)),
PluginType::Unknown => {
log::error!(
"Plugin {} has unknown plugin type and cannot be inferred from entrypoint.",
name
);
return Err(PluginError::PluginUnavailable {
name: name.to_string(),
reason: "Unknown plugin type and cannot be inferred from entrypoint".to_string(),
});
}
}
}
/// Registers all available plugins (internal and external) into a plugin map.
///
/// Internal plugins are registered first, followed by external plugins loaded from
/// the provided [`RawPluginMap`]. Plugins that are unavailable (e.g., missing runtime)
/// are logged and skipped.
///
/// # Arguments
///
/// * `external_plugins` - Map of external plugin configurations
///
/// # Returns
///
/// Returns a [`PluginMap`] containing all registered plugins, or a [`PluginError`] if
/// a plugin manifest cannot be read or parsed.
pub fn register(
external_plugins: RawPluginMap,
list: Option<&HashSet<String>>,
) -> Result<PluginMap, PluginError> {
let mut plugins: PluginMap = HashMap::new();
let mut count = 0;
// Register internal plugin
for i in internal::get_internal_plugin() {
let name = i.name.to_string();
match list {
Some(list) if !list.contains(&name) => {
log::debug!("Plugin {} not in list, skipping", name);
continue;
}
_ => {}
}
log::debug!("Registering internal plugin {}", i.name);
plugins.insert(i.name.to_string(), FinalizePlugin::new(i.entry));
count += 1;
}
// Register external plugin
for (name, data) in external_plugins.into_iter() {
match list {
Some(list) if !list.contains(&name) => {
log::debug!("Plugin {} not in list, skipping", name);
continue;
}
_ => {}
}
log::debug!("Registering external plugin {}", name);
let (_source, config) = data.into_iter().next().unwrap_or_else(|| {
panic!(
"Internal Error: Plugin {} has no source configured, this should have been caught by validation!",
name
)
});
// Assume every plugin prepared (or definitely unavailable) now
if let Some(reason) = config.runtime.error {
log::warn!("Plugin {} is not available: {}", name, reason);
continue;
}
let metadata = read_manifest(config.runtime.fspath, &name)?;
let plugin_type = get_plugin_type(metadata.plugin_type.clone(), &metadata.entrypoint);
let entry = get_entry(plugin_type, &name)?;
plugins.insert(
name,
FinalizePlugin::new(entry)
.with_metadata(metadata)
.with_argument(config.config),
);
count += 1;
}
log::info!("Registered {} plugins", count);
Ok(plugins)
}
/// Calls a registered plugin by name, passing the configuration, execution context and event sender.
///
/// # Arguments
///
/// * `plugins` - Map of registered plugin names to their [`FinalizePlugin`] instances
/// * `name` - Name of the plugin to invoke
/// * `config` - Configuration value passed to the plugin
/// * `ctx` - Execution context for the plugin
/// * `event_tx` - Event sender for plugin lifecycle events
///
/// # Returns
///
/// Returns `Ok(())` on successful plugin execution, or a [`PluginError`] if the plugin
/// is not found or execution fails.
pub async fn call_named_plugin(
plugins: &PluginMap,
name: &str,
config: serde_yaml::Value,
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> PluginResult {
let plugin = plugins
.get(name)
.ok_or_else(|| PluginError::UnknownPlugin(name.to_string()))?;
plugin.call(config, ctx, event_tx).await
}
@@ -0,0 +1,19 @@
use crate::finalize::plugin::PluginMetadata;
use crate::finalize::plugin::PluginError;
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
use async_trait::async_trait;
pub struct Dylib;
#[async_trait]
impl AsyncPluginFn for Dylib {
async fn call(
&self,
_metadata: &PluginMetadata,
_argument: serde_yaml::Value,
_ctx: &ExecutionContext,
_event_tx: &EventSender,
) -> Result<(), PluginError> {
unimplemented!("Dylib plugin is not implemented yet");
}
}
+111
View File
@@ -0,0 +1,111 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use std::collections::HashSet;
use crate::finalize::fetch;
use crate::finalize::{
PluginConfig,
plugin::{PluginError, RawPluginMap},
};
use std::path::Path;
mod checksum;
mod extract;
mod git;
mod http;
mod types;
pub use types::FetchArgument;
enum URLScheme {
Unknown,
Git,
Http,
Https,
File,
}
impl URLScheme {
fn parse(url: &str) -> (Self, String) {
match url.split_once("://") {
Some((prefix, rest)) => {
let scheme = match prefix {
"git" => URLScheme::Git,
"https" => URLScheme::Https,
"http" => URLScheme::Http,
"file" => URLScheme::File,
_ => URLScheme::Unknown,
};
(scheme, rest.to_string())
}
None => (URLScheme::Unknown, url.to_string()),
}
}
}
pub async fn fetch_plugin(
url: &str,
name: &str,
basedir: &Path,
argument: FetchArgument,
) -> Result<(), PluginError> {
let (scheme, path) = URLScheme::parse(url);
match scheme {
URLScheme::Git => {
// NOTE: as a practical compromise, we use git command for git clone
// gix will be supported later, with ONLY https scheme
// TODO: Implement gix
log::info!("Fetching git plugin: {}", url);
git::fetch_git_plugin(&path, name, basedir, argument.shallow).await
}
URLScheme::Http | URLScheme::Https => {
log::info!("Fetching http plugin: {}", url);
let checksum = argument.get_checksum();
http::fetch_http_plugin(url, name, basedir, checksum).await
}
URLScheme::File => {
// TODO: Support client mode
// NOTE: In standalone mode, we just assume path is well-defined and utilize
log::info!("Fetching file plugin: {}", url);
return Ok(());
}
URLScheme::Unknown => {
log::error!("Unknown URL scheme in plugin URL: {}", url);
return Err(PluginError::GeneralError(format!(
"Unknown URL scheme in plugin URL: {}",
url
)));
}
}
}
pub async fn fetch_plugins(
plugin_path: &Path,
plugin_config: &mut RawPluginMap,
list: Option<&HashSet<String>>,
) -> Result<(), PluginError> {
log::info!("Preparing to download {} plugins", plugin_config.len());
for (name, data) in plugin_config.iter_mut() {
match list {
Some(list) if !list.contains(name) => {
log::debug!("Plugin {} not in list, skipping", name);
continue;
}
_ => {}
}
log::debug!("Downloading plugin {}", name);
let (source, config) = data.iter_mut().next().ok_or_else(|| {
PluginError::PluginUnavailable {
name: name.clone(),
reason: "no source configured".to_string(),
}
})?;
let fetch_argument = FetchArgument {
checksum: config.checksum.clone(),
shallow: config.shallow,
};
fetch::fetch_plugin(&source, &name, &plugin_path, fetch_argument).await?;
log::debug!("Storing plugin {} to {}", name, plugin_path.display());
config.runtime.fspath = Some(plugin_path.join(name));
}
Ok(())
}
@@ -0,0 +1,53 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use super::types::ChecksumType;
use sha2::{Digest, Sha256, Sha512};
pub fn detect_checksum_type(checksum: &str) -> Result<Option<ChecksumType>, String> {
match checksum.len() {
0 => Ok(None),
32 => Err(format!(
"MD5 (first 8 chars: {}) is not supported for security reasons. Use SHA256 (64 chars) or SHA512 (128 chars)",
&checksum[..8.min(checksum.len())]
)),
40 => Err(format!(
"SHA1 (first 8 chars: {}) is deprecated and not supported. Use SHA256 (64 chars) or SHA512 (128 chars)",
&checksum[..8.min(checksum.len())]
)),
64 => Ok(Some(ChecksumType::Sha256)),
128 => Ok(Some(ChecksumType::Sha512)),
_ => Err(format!(
"Invalid checksum length: {}. Expected 0 (disabled), 64 (SHA256), or 128 (SHA512). First 8 chars: {}",
checksum.len(),
&checksum[..8.min(checksum.len())]
)),
}
}
pub fn verify_checksum(
data: &[u8],
expected: &str,
checksum_type: ChecksumType,
) -> Result<(), String> {
let actual = match checksum_type {
ChecksumType::Sha256 => {
let mut hasher = Sha256::new();
hasher.update(data);
format!("{:x}", hasher.finalize())
}
ChecksumType::Sha512 => {
let mut hasher = Sha512::new();
hasher.update(data);
format!("{:x}", hasher.finalize())
}
};
if actual.eq_ignore_ascii_case(expected) {
Ok(())
} else {
Err(format!(
"Checksum mismatch: expected {}..., got {}...",
&expected[..8.min(expected.len())],
&actual[..8.min(actual.len())]
))
}
}
@@ -0,0 +1,88 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use std::path::PathBuf;
use crate::finalize::plugin::PluginError;
use tokio::task;
#[derive(Debug, Clone, Copy)]
pub enum CompressionType {
Gzip,
Zstd,
None,
}
impl CompressionType {
pub fn from_path(path: &std::path::Path) -> Result<Self, String> {
let name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
Ok(CompressionType::Gzip)
} else if name.ends_with(".tar.zst") || name.ends_with(".tar.zstd") || name.ends_with(".tzst") {
Ok(CompressionType::Zstd)
} else if name.ends_with(".tar") {
Ok(CompressionType::None)
} else {
Err(format!(
"Unsupported archive format: '{}'. Supported formats: .tar, .tar.gz, .tgz, .tar.zst, .tar.zstd, .tzst",
name
))
}
}
}
pub async fn extract_tar(
archive: &PathBuf,
destdir: &PathBuf,
compression: CompressionType,
) -> Result<(), PluginError> {
if destdir.exists() {
tokio::fs::remove_dir_all(destdir).await.map_err(|e|
PluginError::GeneralError(format!("Failed to remove existing directory: {}", e))
)?;
}
tokio::fs::create_dir_all(destdir).await.map_err(|e|
PluginError::GeneralError(format!("Failed to create directory: {}", e))
)?;
let archive = archive.clone();
let destdir = destdir.clone();
task::spawn_blocking(move || {
let file = std::fs::File::open(&archive).map_err(|e|
PluginError::GeneralError(format!("Failed to open archive: {}", e))
)?;
match compression {
CompressionType::Gzip => {
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive.unpack(&destdir).map_err(|e|
PluginError::GeneralError(format!("Failed to unpack archive: {}", e))
)?;
}
CompressionType::Zstd => {
let decoder = zstd::stream::read::Decoder::new(file).map_err(|e|
PluginError::GeneralError(format!("Failed to create zstd decoder: {}", e))
)?;
let mut archive = tar::Archive::new(decoder);
archive.unpack(&destdir).map_err(|e|
PluginError::GeneralError(format!("Failed to unpack archive: {}", e))
)?;
}
CompressionType::None => {
let mut archive = tar::Archive::new(file);
archive.unpack(&destdir).map_err(|e|
PluginError::GeneralError(format!("Failed to unpack archive: {}", e))
)?;
}
};
Ok::<(), PluginError>(())
})
.await
.map_err(|e| PluginError::GeneralError(format!("Task join error: {}", e)))??;
Ok(())
}
@@ -0,0 +1,131 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use std::path::Path;
use tokio::process::Command;
use crate::finalize::plugin::PluginError;
#[derive(PartialEq)]
pub enum GitVersion {
None,
Single(String),
Mixed(String, String),
}
pub fn parse_git_url(url: &str) -> (String, GitVersion) {
if let Some((base, version)) = url.split_once('@') {
if version.contains(":") {
let (tag, commit) = version.split_once(':').unwrap();
return (
base.to_string(),
GitVersion::Mixed(tag.to_string(), commit.to_string()),
);
}
return (base.to_string(), GitVersion::Single(version.to_string()));
}
(url.to_string(), GitVersion::None)
}
pub async fn git_clone_and_checkout(
baseurl: &str,
version: &str,
dest: &Path,
name: &str,
shallow: bool,
) -> Result<(), PluginError> {
if dest.exists() {
tokio::fs::remove_dir_all(dest)
.await
.map_err(|e| PluginError::FetchError {
name: name.to_string(),
url: baseurl.to_string(),
reason: format!("Failed to remove existing directory: {}", e),
})?;
}
let mut cmd = Command::new("git");
cmd.arg("clone");
if shallow {
cmd.arg("--depth").arg("1");
}
cmd.arg(baseurl).arg(dest);
let output = cmd.output().await.map_err(|e| PluginError::FetchError {
name: name.to_string(),
url: baseurl.to_string(),
reason: e.to_string(),
})?;
if !output.status.success() {
return Err(PluginError::FetchError {
name: name.to_string(),
url: baseurl.to_string(),
reason: String::from_utf8_lossy(&output.stderr).to_string(),
});
}
let mut cmd = Command::new("git");
cmd.arg("checkout").arg(version).current_dir(dest);
let output = cmd.output().await.map_err(|e| PluginError::FetchError {
name: name.to_string(),
url: baseurl.to_string(),
reason: e.to_string(),
})?;
if !output.status.success() {
return Err(PluginError::FetchError {
name: name.to_string(),
url: baseurl.to_string(),
reason: String::from_utf8_lossy(&output.stderr).to_string(),
});
}
Ok(())
}
pub async fn fetch_git_plugin(
path: &str,
name: &str,
basedir: &Path,
shallow: Option<bool>,
) -> Result<(), PluginError> {
let (baseurl, version) = parse_git_url(path);
match version {
GitVersion::None => {
log::error!("Version should be specified for git plugin URL: {}", path);
Err(PluginError::FetchError {
name: name.to_string(),
url: path.to_string(),
reason: "Version should be specified as either a tag or a commit hash.".to_string(),
})
}
GitVersion::Single(version) => {
log::debug!("Fetching git plugin: {}@{}", name, version);
let shallow_flag = shallow.unwrap_or(false);
git_clone_and_checkout(
&baseurl,
&version.to_string(),
&basedir.join(name),
name,
shallow_flag,
)
.await
}
GitVersion::Mixed(tag, commit) => {
log::debug!("Fetching git plugin: {}@{}/{}", name, tag, commit);
let shallow_flag = shallow.unwrap_or(false);
let result = git_clone_and_checkout(
&baseurl,
&tag,
&basedir.join(name),
name,
shallow_flag,
)
.await;
if result.is_err() {
log::warn!("Failed to clone with tag, trying commit hash. URL: {}", path);
return git_clone_and_checkout(
&baseurl,
&commit,
&basedir.join(name),
name,
false,
)
.await;
}
result
}
}
}
@@ -0,0 +1,104 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use std::path::Path;
use crate::finalize::plugin::PluginError;
use super::checksum::{detect_checksum_type, verify_checksum};
use super::extract::{extract_tar, CompressionType};
pub async fn download_to_file(
url: &str,
dest: &Path,
name: &str,
) -> Result<(), PluginError> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.map_err(|e| PluginError::GeneralError(format!("Failed to build HTTP client: {}", e)))?;
let response = client
.get(url)
.send()
.await
.map_err(|e| PluginError::FetchError {
name: name.to_string(),
url: url.to_string(),
reason: format!("HTTP request failed: {}", e),
})?;
let status = response.status();
if !status.is_success() {
return Err(PluginError::FetchError {
name: name.to_string(),
url: url.to_string(),
reason: format!("HTTP error: {}", status),
});
}
let bytes = response.bytes().await.map_err(|e| PluginError::FetchError {
name: name.to_string(),
url: url.to_string(),
reason: format!("Failed to read response body: {}", e),
})?;
tokio::fs::write(dest, &bytes).await.map_err(|e| PluginError::FetchError {
name: name.to_string(),
url: url.to_string(),
reason: format!("Failed to write file: {}", e),
})?;
Ok(())
}
fn extract_archive_extension(url: &str) -> &str {
let path_part = url.split('?').next().unwrap_or(url).split('#').next().unwrap_or(url);
let file_part = path_part.rsplit('/').next().unwrap_or("");
let lower = file_part.to_lowercase();
if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") {
".tar.gz"
} else if lower.ends_with(".tar.zst") || lower.ends_with(".tar.zstd") || lower.ends_with(".tzst") {
".tar.zst"
} else if lower.ends_with(".tar") {
".tar"
} else {
".tar"
}
}
pub async fn fetch_http_plugin(
url: &str,
name: &str,
basedir: &Path,
checksum_str: Option<&str>,
) -> Result<(), PluginError> {
let temp_dir = std::env::temp_dir().join("workshop-plugins");
tokio::fs::create_dir_all(&temp_dir).await.ok();
let ext = extract_archive_extension(url);
let temp_file = temp_dir.join(format!("{}-{}{}", name, uuid::Uuid::new_v4(), ext));
download_to_file(url, &temp_file, name).await?;
if let Some(checksum) = checksum_str {
if !checksum.is_empty() {
let bytes = tokio::fs::read(&temp_file).await.map_err(|e| {
PluginError::GeneralError(format!("Failed to read downloaded file: {}", e))
})?;
let checksum_type = detect_checksum_type(checksum)
.map_err(|e| PluginError::GeneralError(e))?;
if let Some(ct) = checksum_type {
verify_checksum(&bytes, checksum, ct).map_err(|e| {
PluginError::GeneralError(e)
})?;
}
}
}
let compression = CompressionType::from_path(&temp_file)
.map_err(|e| PluginError::GeneralError(e))?;
let destdir = basedir.join(name);
extract_tar(&temp_file, &destdir, compression).await?;
tokio::fs::remove_file(&temp_file).await.ok();
Ok(())
}
@@ -0,0 +1,22 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub enum ChecksumType {
Sha256,
Sha512,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct FetchArgument {
#[serde(default)]
pub checksum: Option<String>,
#[serde(default)]
pub shallow: Option<bool>,
}
impl FetchArgument {
pub fn get_checksum(&self) -> Option<&str> {
self.checksum.as_deref().filter(|s| !s.is_empty())
}
}
@@ -0,0 +1,31 @@
use crate::finalize::plugin::AsyncPluginFn;
mod insitenotify;
mod mail;
mod satori;
mod webhook;
pub mod dummy;
pub struct InternalPlugin {
pub name: &'static str,
pub entry: Box<dyn AsyncPluginFn>,
}
pub fn get_internal_plugin() -> Vec<InternalPlugin> {
let mut internal_plugins: Vec<InternalPlugin> = Vec::new();
internal_plugins.push(InternalPlugin {
name: "in-site-notify",
entry: Box::new(insitenotify::InsiteNotify),
});
internal_plugins.push(InternalPlugin {
name: "mail",
entry: Box::new(mail::Mail),
});
internal_plugins.push(InternalPlugin {
name: "satori",
entry: Box::new(satori::Satori),
});
internal_plugins.push(InternalPlugin {
name: "webhook",
entry: Box::new(webhook::Webhook),
});
internal_plugins
}
@@ -0,0 +1,19 @@
use crate::finalize::plugin::PluginError;
use crate::finalize::plugin::PluginMetadata;
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
use async_trait::async_trait;
pub struct _Dummy;
#[async_trait]
impl AsyncPluginFn for _Dummy {
async fn call(
&self,
_metadata: &PluginMetadata,
_argument: serde_yaml::Value,
_ctx: &ExecutionContext,
_event_tx: &EventSender,
) -> Result<(), PluginError> {
Ok(())
}
}
@@ -0,0 +1,18 @@
use crate::finalize::plugin::PluginMetadata;
use crate::{EventSender, ExecutionContext, finalize::{error::PluginError, plugin::AsyncPluginFn}};
use async_trait::async_trait;
pub struct InsiteNotify;
#[async_trait]
impl AsyncPluginFn for InsiteNotify {
async fn call(
&self,
_metadata: &PluginMetadata,
_argument: serde_yaml::Value,
_ctx: &ExecutionContext,
_event_tx: &EventSender,
) -> Result<(), PluginError> {
unimplemented!("Requires workshop-base to be implemented!");
}
}
@@ -0,0 +1,122 @@
notification:
1:
# 最简配置
mail: # 插件名,不可变动,下同
to: "admin@example.com"
from: "ci@example.com"
subject: "Build {{ build.status }}: {{ pipeline.name }}"
body: "Pipeline {{ pipeline.name }} build #{{ build.id }} finished with status: {{ build.status }}"
smtp:
host: "smtp.gmail.com"
port: 587
username: "{{ secret.smtp_user }}"
password: "{{ secret.smtp_pass }}"
# 高级配置(完整功能)
mail:
# ===== 收件人配置 =====
to:
- "team@example.com"
- "admin@example.com"
- "{{ commit.author_email }}" # 模板变量支持
cc:
- "manager@example.com"
bcc:
- "archive@example.com"
# 多态,单字符串=email
from:
name: "HoneyBiscuitWorkshop CI"
email: "ci@example.com"
reply_to: "support@example.com"
# 可选:使用预定义模板
schema: "custom" # 或 "default",或定义在notification.schema里的字段
# ===== 主题配置,schema=custom时可用 =====
subject: "[{{ build.status | upper }}] {{ pipeline.name }} #{{ build.id }} - {{ commit.message | truncate(50) }}"
# ===== 正文配置,schema=custom时可用 =====
body: |
<html>
<body style="font-family: Arial, sans-serif;">
<h2 style="color: {{ build.status_color }};">
Build {{ build.status | title }}
</h2>
<table>
<tr><td><strong>Pipeline:</strong></td><td>{{ pipeline.name }}</td></tr>
<tr><td><strong>Build ID:</strong></td><td>{{ build.id }}</td></tr>
<tr><td><strong>Status:</strong></td><td>{{ build.status }}</td></tr>
<tr><td><strong>Duration:</strong></td><td>{{ build.duration }}</td></tr>
<tr><td><strong>Commit:</strong></td><td>{{ commit.hash | slice(0, 7) }}</td></tr>
<tr><td><strong>Author:</strong></td><td>{{ commit.author }}</td></tr>
<tr><td><strong>Message:</strong></td><td>{{ commit.message }}</td></tr>
</table>
<p>
<a href="{{ build.url }}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
View Build Details
</a>
</p>
</body>
</html>
# ===== SMTP 配置 =====
smtp:
host: "smtp.gmail.com"
port: 587
# 认证方式:password / oauth2 / none
auth:
type: "password"
username: "{{ secret.smtp_user }}"
password: "{{ secret.smtp_pass }}"
# 加密方式:tls / starttls / none
encryption: "starttls"
# 连接超时
connect_timeout: "30s"
timeout: "90s"
retry: 3
fallible: false
# 仅作参考,暂不实现:
# OAuth2 配置示例(Gmail
mail:
to: "user@example.com"
from: "ci@example.com"
subject: "Build Notification"
body: "Build completed"
schema: "default"
smtp:
host: "smtp.gmail.com"
port: 465
auth:
type: "oauth2"
client_id: "{{ secret.gmail_client_id }}"
client_secret: "{{ secret.gmail_client_secret }}"
refresh_token: "{{ secret.gmail_refresh_token }}"
encryption: "tls"
# 企业级配置(Exchange/Office365
mail:
to: "team@company.com"
from: "ci@company.com"
subject: "{{ pipeline.name }} Build {{ build.status }}"
schema: "default"
smtp:
host: "smtp.office365.com"
port: 587
auth:
type: "password"
username: "{{ secret.exchange_user }}"
password: "{{ secret.exchange_pass }}"
encryption: "starttls"
# 企业环境可能需要自定义 TLS 配置
tls:
verify: true
ca_cert: "{{ secret.exchange_cert }}" # 可选
@@ -0,0 +1,187 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use crate::finalize::plugin::PluginMetadata;
use crate::finalize::plugin::PluginError;
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
use async_trait::async_trait;
use std::time::Duration;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use lettre::message::{header, MultiPart, SinglePart};
use tokio::time::timeout;
mod config;
mod template;
pub use config::*;
use template::*;
pub struct Mail;
#[async_trait]
impl AsyncPluginFn for Mail {
async fn call(
&self,
_metadata: &PluginMetadata,
argument: serde_yaml::Value,
ctx: &ExecutionContext,
_event_tx: &EventSender,
) -> Result<(), PluginError> {
let config: MailConfig = serde_yaml::from_value(argument)
.map_err(|e| PluginError::GeneralError(format!("Invalid mail config: {}", e)))?;
if let Err(errors) = config.validate() {
return Err(PluginError::GeneralError(
format!("Mail config validation failed: {:?}", errors)
));
}
let template_data = prepare_template_data(ctx);
let (subject_template, body_template_html, body_template_text) = match config.schema {
Schema::Default => {
let subject = config.subject.as_deref()
.unwrap_or(DefaultTemplates::SUBJECT);
let body_html = config.body.as_deref()
.unwrap_or(DefaultTemplates::BODY_HTML);
(subject.to_string(), body_html.to_string(), DefaultTemplates::BODY_TEXT.to_string())
}
Schema::Custom => {
let subject = config.subject.clone().unwrap();
let body_html = config.body.clone().unwrap();
(subject, body_html.clone(), body_html)
}
};
let subject = render_template(&subject_template, &template_data)
.map_err(|e| PluginError::GeneralError(format!("Failed to render subject: {}", e)))?;
let body_html = render_template(&body_template_html, &template_data)
.map_err(|e| PluginError::GeneralError(format!("Failed to render body_html: {}", e)))?;
let body_text = render_template(&body_template_text, &template_data)
.map_err(|e| PluginError::GeneralError(format!("Failed to render body_text(?): {}", e)))?;
let email = build_email(&config, subject, body_html, body_text)?;
send_email(&config, email).await
}
}
fn build_email(
config: &MailConfig,
subject: String,
body_html: String,
body_text: String,
) -> Result<Message, PluginError> {
let from_str = config.from.to_string();
let from: lettre::message::Mailbox = from_str.parse()
.map_err(|e| PluginError::GeneralError(format!("Invalid from address '{}': {}", from_str, e)))?;
let to_addrs: Vec<String> = config.to.to_vec();
if to_addrs.is_empty() {
return Err(PluginError::GeneralError("No recipients specified".to_string()));
}
let mut message_builder = Message::builder()
.from(from)
.subject(subject);
for addr in &to_addrs {
let mailbox: lettre::message::Mailbox = addr.parse()
.map_err(|e| PluginError::GeneralError(format!("Invalid to address '{}': {}", addr, e)))?;
message_builder = message_builder.to(mailbox);
}
if let Some(cc) = &config.cc {
for addr in cc.to_vec() {
let mailbox: lettre::message::Mailbox = addr.parse()
.map_err(|e| PluginError::GeneralError(format!("Invalid cc address '{}': {}", addr, e)))?;
message_builder = message_builder.cc(mailbox);
}
}
if let Some(bcc) = &config.bcc {
for addr in bcc.to_vec() {
let mailbox: lettre::message::Mailbox = addr.parse()
.map_err(|e| PluginError::GeneralError(format!("Invalid bcc address '{}': {}", addr, e)))?;
message_builder = message_builder.bcc(mailbox);
}
}
if let Some(reply_to) = &config.reply_to {
let mailbox: lettre::message::Mailbox = reply_to.parse()
.map_err(|e| PluginError::GeneralError(format!("Invalid reply-to address '{}': {}", reply_to, e)))?;
message_builder = message_builder.reply_to(mailbox);
}
let message = message_builder
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(header::ContentType::TEXT_PLAIN)
.body(body_text)
)
.singlepart(
SinglePart::builder()
.header(header::ContentType::TEXT_HTML)
.body(body_html)
)
)
.map_err(|e| PluginError::GeneralError(format!("Failed to build email: {}", e)))?;
Ok(message)
}
async fn send_email(
config: &MailConfig,
email: Message,
) -> Result<(), PluginError> {
let connect_timeout = if config.smtp.connect_timeout_ms == 0 {
None
} else {
Some(Duration::from_millis(config.smtp.connect_timeout_ms))
};
let mut transport_builder = match config.smtp.encryption {
Encryption::Tls => {
log::debug!("Using TLS encryption");
AsyncSmtpTransport::<Tokio1Executor>::relay(&config.smtp.host)
.map_err(|e| PluginError::GeneralError(format!("Invalid SMTP host: {}", e)))?
}
Encryption::Starttls => {
log::debug!("Using StartTLS encryption");
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.smtp.host)
.map_err(|e| PluginError::GeneralError(format!("Invalid SMTP host: {}", e)))?
}
Encryption::None => {
log::debug!("Using NO encryption");
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.smtp.host)
}
};
transport_builder = transport_builder.port(config.smtp.port);
match config.smtp.auth.auth_type {
AuthType::Password => {
log::debug!("Using password auth");
transport_builder = transport_builder.credentials((
config.smtp.auth.username.clone(),
config.smtp.auth.password.clone()
).into());
}
AuthType::None => {
log::debug!("Using no auth");
}
}
let transport = transport_builder.build();
let result = match connect_timeout {
Some(t) => timeout(t, transport.send(email)).await,
None => Ok(transport.send(email).await),
};
result
.map_err(|_| PluginError::GeneralError("SMTP connection timed out".to_string()))?
.map_err(|e| PluginError::GeneralError(format!("SMTP error: {}", e)))?;
Ok(())
}
@@ -0,0 +1,191 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use crate::types::time::deserialize_duration_ms;
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum EmailAddress {
String(String),
Object { name: String, email: String },
}
impl EmailAddress {
pub fn to_string(&self) -> String {
match self {
EmailAddress::String(email) => email.clone(),
EmailAddress::Object { name, email } => {
if name.is_empty() {
email.clone()
} else {
format!("{} <{}>", name, email)
}
}
}
}
pub fn email(&self) -> &str {
match self {
EmailAddress::String(email) => email.as_str(),
EmailAddress::Object { email, .. } => email.as_str(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum Recipients {
Single(String),
Multiple(Vec<String>),
}
impl Recipients {
pub fn to_vec(&self) -> Vec<String> {
match self {
Recipients::Single(s) => vec![s.clone()],
Recipients::Multiple(v) => v.clone(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum AuthType {
None,
Password,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AuthConfig {
#[serde(rename = "type")]
pub auth_type: AuthType,
pub username: String,
pub password: String,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum Encryption {
Tls,
Starttls,
None,
}
#[derive(Debug, Deserialize, Clone)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub auth: AuthConfig,
pub encryption: Encryption,
#[serde(
default = "default_connect_timeout_ms",
deserialize_with = "deserialize_duration_ms"
)]
pub connect_timeout_ms: u64,
}
fn default_connect_timeout_ms() -> u64 {
30_000
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum Schema {
Default,
Custom,
}
fn default_schema() -> Schema {
Schema::Default
}
#[derive(Debug, Deserialize, Clone)]
pub struct MailConfig {
pub to: Recipients,
#[serde(default)]
pub cc: Option<Recipients>,
#[serde(default)]
pub bcc: Option<Recipients>,
pub from: EmailAddress,
#[serde(default)]
pub reply_to: Option<String>,
#[serde(default = "default_schema")]
pub schema: Schema,
pub subject: Option<String>,
pub body: Option<String>,
pub smtp: SmtpConfig,
}
impl MailConfig {
pub fn validate(&self) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
if matches!(self.schema, Schema::Custom) {
if self.subject.is_none() {
errors.push("subject: required when schema=custom".to_string());
}
if self.body.is_none() {
errors.push("body: required when schema=custom".to_string());
}
}
if self.to.to_vec().is_empty() {
errors.push("to: must have at least one recipient".to_string());
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
pub struct DefaultTemplates;
impl DefaultTemplates {
pub const SUBJECT: &'static str =
"[{{ build.status | upper }}] {{ pipeline.name }} #{{ build.id }}";
pub const BODY_HTML: &'static str = r#"<!DOCTYPE html>
<html>
<body style="font-family: Arial, sans-serif;">
<h2 style="color: {{ build.status_color }};">
Build {{ build.status | title }}
</h2>
<table>
<tr><td><strong>Pipeline:</strong></td><td>{{ pipeline.name }}</td></tr>
<tr><td><strong>Build ID:</strong></td><td>{{ build.id }}</td></tr>
<tr><td><strong>Status:</strong></td><td>{{ build.status }}</td></tr>
<tr><td><strong>Duration:</strong></td><td>{{ build.duration }}</td></tr>
<tr><td><strong>Author:</strong></td><td>{{ commit.author }}</td></tr>
<tr><td><strong>Message:</strong></td><td>{{ commit.message }}</td></tr>
</table>
<p>
<a href="{{ build.url }}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
View Build Details
</a>
</p>
</body>
</html>"#;
pub const BODY_TEXT: &'static str = r#"Build {{ build.status | title }}
==============================
Pipeline: {{ pipeline.name }}
Build ID: {{ build.id }}
Status: {{ build.status }}
Duration: {{ build.duration }}
Commit: {{ commit.hash }}
Author: {{ commit.author }}
Message: {{ commit.message }}
View details: {{ build.url }}"#;
}
@@ -0,0 +1,56 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use crate::ExecutionContext;
use minijinja::Environment;
use std::collections::HashMap;
pub fn prepare_template_data(ctx: &ExecutionContext) -> HashMap<String, serde_json::Value> {
let mut data = HashMap::new();
data.insert(
"pipeline".to_string(),
serde_json::json!({
"name": ctx.pipeline_name,
}),
);
data.insert(
"build".to_string(),
serde_json::json!({
"id": ctx.task_id,
"status": "success",
"status_color": "green",
"duration": "0s",
"url": "",
}),
);
data.insert(
"commit".to_string(),
serde_json::json!({
"hash": "",
"short_hash": "",
"message": "",
"author": "",
"author_email": "",
}),
);
data
}
pub fn render_template(
template: &str,
data: &HashMap<String, serde_json::Value>,
) -> Result<String, String> {
let env = Environment::new();
let tmpl = env
.template_from_str(template)
.map_err(|e| format!("Template parse error: {}", e))?;
let result = tmpl
.render(data)
.map_err(|e| format!("Template render error: {}", e))?;
Ok(result)
}
@@ -0,0 +1,19 @@
use crate::finalize::plugin::PluginMetadata;
use crate::finalize::plugin::PluginError;
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
use async_trait::async_trait;
pub struct Satori;
#[async_trait]
impl AsyncPluginFn for Satori {
async fn call(
&self,
_metadata: &PluginMetadata,
_argument: serde_yaml::Value,
_ctx: &ExecutionContext,
_event_tx: &EventSender,
) -> Result<(), PluginError> {
Ok(())
}
}
@@ -0,0 +1,19 @@
use crate::finalize::plugin::PluginMetadata;
use crate::finalize::plugin::PluginError;
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
use async_trait::async_trait;
pub struct Webhook;
#[async_trait]
impl AsyncPluginFn for Webhook {
async fn call(
&self,
_metadata: &PluginMetadata,
_argument: serde_yaml::Value,
_ctx: &ExecutionContext,
_event_tx: &EventSender,
) -> Result<(), PluginError> {
Ok(())
}
}
@@ -0,0 +1,41 @@
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Deserialize, Clone)]
pub struct PluginMetadata {
pub name: String,
pub version: String,
pub entrypoint: String,
#[serde(default)]
pub plugin_type: PluginType,
#[serde(flatten)]
pub value: serde_yaml::Value,
#[serde(skip)]
pub fspath: PathBuf,
}
impl Default for PluginMetadata {
fn default() -> Self {
Self {
name: String::new(),
version: String::new(),
entrypoint: String::new(),
plugin_type: PluginType::Unknown,
value: serde_yaml::Value::Null,
fspath: PathBuf::new(),
}
}
}
#[derive(Deserialize, Default, PartialEq, Eq, Clone)]
pub enum PluginType {
#[default]
Unknown,
Shell,
Rhai,
Dylib,
}
@@ -0,0 +1,19 @@
use crate::finalize::plugin::PluginMetadata;
use crate::finalize::plugin::PluginError;
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
use async_trait::async_trait;
pub struct Rhai;
#[async_trait]
impl AsyncPluginFn for Rhai {
async fn call(
&self,
_metadata: &PluginMetadata,
_argument: serde_yaml::Value,
_ctx: &ExecutionContext,
_event_tx: &EventSender,
) -> Result<(), PluginError> {
todo!("Rhai plugin is not implemented yet");
}
}
+376
View File
@@ -0,0 +1,376 @@
use crate::constant::FINALIZE_SHELL_ENVPREFIX;
use crate::Engine;
use crate::finalize::plugin::PluginError;
use crate::finalize::plugin::PluginMetadata;
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
use async_trait::async_trait;
use std::collections::HashMap;
pub struct Shell;
/// Parses a YAML value used as a mapping key into a string.
///
/// Since YAML keys can be strings, numbers, or booleans, this function
/// normalizes them into a consistent [`String`] representation.
///
/// # Arguments
///
/// * `key` - A YAML value representing a mapping key
///
/// # Returns
///
/// A string representation of the key. Returns `"?"` for unsupported types
/// (such as mappings or sequences) while logging a warning.
///
/// # See Also
///
/// [`convert_argument`] — which calls this function for key parsing
// Workaround flexible key in yaml
fn parse_key(key: serde_yaml::Value) -> String {
match key {
serde_yaml::Value::String(s) => s,
serde_yaml::Value::Number(n) => n.to_string(),
serde_yaml::Value::Bool(b) => b.to_string(),
_ => {
log::warn!("Unsupported key type in mapping: {:?}", key);
String::from("?")
}
}
}
/// Converts a YAML argument value into shell environment variable key-value pairs.
///
/// This function recursively traverses YAML structures and flattens them into
/// environment variables suitable for shell script consumption:
///
/// - **Mapping** → keys joined with `_` (e.g., `{a: {b: 1}}` becomes `PREFIX_A_B=1`)
/// - **Sequence** → shell array syntax (e.g., `["x", "y"]` becomes `PREFIX=(x y)`)
/// - **Scalar values** → direct assignment with type-appropriate formatting
///
/// # Arguments
///
/// * `prefix` - The environment variable key prefix (accumulates via recursion)
/// * `argument` - The YAML value to convert
/// * `in_sequence` - Whether currently processing a sequence item (prevents nested arrays)
///
/// # Returns
///
/// A [`HashMap`] of environment variable names to their string values.
///
/// # Notes
///
/// - Keys are uppercased during conversion for shell compatibility
/// - Strings are double-quoted to preserve special characters
/// - Null values are skipped (not emitted as empty strings)
/// - Nested sequences within sequences are not supported and are skipped
///
/// # Examples
///
/// ```ignore
/// let yaml = serde_yaml::from_str("host: docker.io\nuser: admin").unwrap();
/// let result = convert_argument("PLUGIN", yaml, false);
/// // Yields: {"PLUGIN_HOST" => "\"docker.io\"", "PLUGIN_USER" => "\"admin\""}
/// ```
fn convert_argument(prefix: &str, argument: serde_yaml::Value, in_sequence: bool) -> HashMap<String, String> {
let mut envvars: HashMap<String, String> = HashMap::new();
match argument {
serde_yaml::Value::Mapping(mapping) => {
for i in mapping {
let key = parse_key(i.0).to_uppercase();
let value = i.1.clone();
let new_prefix = format!("{}_{}", prefix, key);
let subenvvars = convert_argument(new_prefix.as_str(), value, false);
envvars.extend(subenvvars);
}
}
serde_yaml::Value::Null => {
// NOTE: Null value should be eliminated.
}
serde_yaml::Value::Bool(value) => {
envvars.insert(prefix.to_string(), value.to_string());
}
serde_yaml::Value::String(value) => {
envvars.insert(prefix.to_string(), format!("{}", value));
}
serde_yaml::Value::Number(value) => {
envvars.insert(prefix.to_string(), value.to_string());
},
serde_yaml::Value::Tagged(tagged_value) => {
let subenvvars = convert_argument(prefix, tagged_value.value, in_sequence);
envvars.extend(subenvvars);
}
serde_yaml::Value::Sequence(seq) => {
if in_sequence {
log::warn!("Nested sequence in sequence is not supported, skipping: {}", prefix);
return envvars;
}
let mut array = String::new();
array.push_str("(");
let mut has_mapping = false;
let mut _envvars: HashMap<String, String> = HashMap::new();
for (index, item) in seq.into_iter().enumerate() {
if index > 0 {
array.push_str(" ");
}
let new_prefix = format!("{}_{}", prefix, index);
let subenvvars = convert_argument(new_prefix.as_str(), item.clone(), true);
if item.is_mapping() {
has_mapping = true;
}
else {
array.push_str(subenvvars.get(new_prefix.as_str()).map(|v| v.as_str()).unwrap_or_default());
}
_envvars.extend(subenvvars);
}
if has_mapping {
envvars.extend(_envvars);
}
else {
array.push_str(")");
envvars.insert(prefix.to_string(), array);
}
}
}
envvars
}
#[async_trait]
impl AsyncPluginFn for Shell {
async fn call(
&self,
metadata: &PluginMetadata,
argument: serde_yaml::Value,
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> Result<(), PluginError> {
let engine = Engine::new();
let envvars = convert_argument(FINALIZE_SHELL_ENVPREFIX, argument, false);
let mut local_ctx = ctx.clone();
local_ctx.env_vars.extend(envvars);
engine.execute_script(&metadata.fspath.join(&metadata.entrypoint), &local_ctx, event_tx).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_key_string() {
let key = serde_yaml::Value::String("test_key".to_string());
assert_eq!(parse_key(key), "test_key");
}
#[test]
fn test_parse_key_number() {
let key = serde_yaml::Value::Number(42.into());
assert_eq!(parse_key(key), "42");
let key = serde_yaml::Value::Number(3.14.into());
assert_eq!(parse_key(key), "3.14");
}
#[test]
fn test_parse_key_bool() {
let key = serde_yaml::Value::Bool(true);
assert_eq!(parse_key(key), "true");
let key = serde_yaml::Value::Bool(false);
assert_eq!(parse_key(key), "false");
}
#[test]
fn test_parse_key_unsupported() {
let key = serde_yaml::Value::Null;
assert_eq!(parse_key(key), "?");
let key = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
assert_eq!(parse_key(key), "?");
let key = serde_yaml::Value::Sequence(vec![]);
assert_eq!(parse_key(key), "?");
}
#[test]
fn test_convert_argument_simple_mapping() {
let yaml = serde_yaml::from_str("host: docker.io\nuser: admin").unwrap();
let result = convert_argument("PLUGIN", yaml, false);
// Keys are uppercased in actual call() function
assert_eq!(result.get("PLUGIN_HOST"), Some(&"docker.io".to_string()));
assert_eq!(result.get("PLUGIN_USER"), Some(&"admin".to_string()));
}
#[test]
fn test_convert_argument_nested_mapping() {
let yaml = serde_yaml::from_str("
from:
host: smtp.example.com
port: 587
").unwrap();
let result = convert_argument("CFG", yaml, false);
// Nested keys use uppercase format: CFG_FROM_HOST
assert_eq!(result.get("CFG_FROM_HOST"), Some(&"smtp.example.com".to_string()));
assert_eq!(result.get("CFG_FROM_PORT"), Some(&"587".to_string()));
}
#[test]
fn test_convert_argument_different_types() {
let yaml = serde_yaml::from_str("
enabled: true
count: 42
ratio: 3.14
name: test
empty: ~
").unwrap();
let result = convert_argument("VAR", yaml, false);
assert_eq!(result.get("VAR_ENABLED"), Some(&"true".to_string()));
assert_eq!(result.get("VAR_COUNT"), Some(&"42".to_string()));
assert_eq!(result.get("VAR_RATIO"), Some(&"3.14".to_string()));
assert_eq!(result.get("VAR_NAME"), Some(&"test".to_string()));
assert!(!result.contains_key("VAR_EMPTY"));
}
#[test]
fn test_convert_argument_sequence_simple() {
let yaml = serde_yaml::from_str("tags:\n - latest\n - v1.0\n - stable").unwrap();
let result = convert_argument("CFG", yaml, false);
assert_eq!(result.get("CFG_TAGS"), Some(&"(latest v1.0 stable)".to_string()));
}
#[test]
fn test_convert_argument_sequence_with_mapping() {
let yaml = serde_yaml::from_str("
env:
- name: KEY1
value: val1
- name: KEY2
value: val2
").unwrap();
let result = convert_argument("CFG", yaml, false);
assert_eq!(result.get("CFG_ENV_0_NAME"), Some(&"KEY1".to_string()));
assert_eq!(result.get("CFG_ENV_0_VALUE"), Some(&"val1".to_string()));
assert_eq!(result.get("CFG_ENV_1_NAME"), Some(&"KEY2".to_string()));
assert_eq!(result.get("CFG_ENV_1_VALUE"), Some(&"val2".to_string()));
assert!(!result.contains_key("CFG_ENV"));
}
#[test]
fn test_convert_argument_nested_sequence() {
let yaml = serde_yaml::from_str("
matrix:
- - a
- b
- - c
- d
").unwrap();
let result = convert_argument("CFG", yaml, false);
// Nested sequences are not supported (line 50-52 logs warning and skips)
// So CFG_MATRIX_0 and CFG_MATRIX_1 keys are not created
assert!(!result.contains_key("CFG_MATRIX_0"));
assert!(!result.contains_key("CFG_MATRIX_1"));
// The outer array has empty slots where nested sequences were skipped
// (space separator is still added even for empty values)
assert_eq!(result.get("CFG_MATRIX"), Some(&"( )".to_string()));
}
#[test]
fn test_convert_argument_empty_mapping() {
let yaml = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
let result = convert_argument("CFG", yaml, false);
assert!(result.is_empty());
}
#[test]
fn test_convert_argument_empty_sequence() {
let yaml = serde_yaml::Value::Sequence(vec![]);
let result = convert_argument("CFG", yaml, false);
assert_eq!(result.get("CFG"), Some(&"()".to_string()));
}
#[test]
fn test_convert_argument_null_value() {
let yaml = serde_yaml::from_str("
host: docker.io
token: ~
").unwrap();
let result = convert_argument("CFG", yaml, false);
// Keys are uppercased: CFG_HOST
assert_eq!(result.get("CFG_HOST"), Some(&"docker.io".to_string()));
// Null value is skipped (not set)
assert!(!result.contains_key("CFG_TOKEN"));
}
#[test]
fn test_convert_argument_number_keys() {
let yaml = serde_yaml::from_str("
0: first
1: second
").unwrap();
let result = convert_argument("CFG", yaml, false);
// Number keys are preserved but prefixed: CFG_0, CFG_1
assert_eq!(result.get("CFG_0"), Some(&"first".to_string()));
assert_eq!(result.get("CFG_1"), Some(&"second".to_string()));
}
#[test]
fn test_convert_argument_complex_nested() {
let yaml = serde_yaml::from_str("
plugin:
docker-push:
host: docker.io
image: user/app:tag
options:
force: true
retries: 3
tags:
- latest
- v1.0
").unwrap();
let result = convert_argument("CFG", yaml, false);
// All keys uppercased for shell environment variables
assert_eq!(result.get("CFG_PLUGIN_DOCKER-PUSH_HOST"), Some(&"docker.io".to_string()));
assert_eq!(result.get("CFG_PLUGIN_DOCKER-PUSH_IMAGE"), Some(&"user/app:tag".to_string()));
assert_eq!(result.get("CFG_PLUGIN_DOCKER-PUSH_OPTIONS_FORCE"), Some(&"true".to_string()));
assert_eq!(result.get("CFG_PLUGIN_DOCKER-PUSH_OPTIONS_RETRIES"), Some(&"3".to_string()));
assert_eq!(result.get("CFG_PLUGIN_DOCKER-PUSH_TAGS"), Some(&"(latest v1.0)".to_string()));
}
#[test]
fn test_convert_argument_special_chars_in_values() {
let yaml = serde_yaml::from_str("
url: https://example.com/path?query=value
path: /workspace/output.tar.gz
token: 'Bearer abc123'
").unwrap();
let result = convert_argument("CFG", yaml, false);
// Keys uppercased: CFG_URL, CFG_PATH, CFG_TOKEN
assert_eq!(result.get("CFG_URL"), Some(&"https://example.com/path?query=value".to_string()));
assert_eq!(result.get("CFG_PATH"), Some(&"/workspace/output.tar.gz".to_string()));
assert_eq!(result.get("CFG_TOKEN"), Some(&"Bearer abc123".to_string()));
}
#[test]
fn test_convert_argument_quoted_strings_preserved() {
let yaml = serde_yaml::from_str("
msg: 'Hello World'
cmd: \"echo 'test'\"
").unwrap();
let result = convert_argument("CFG", yaml, false);
// Keys uppercased: CFG_MSG, CFG_CMD
assert_eq!(result.get("CFG_MSG"), Some(&"Hello World".to_string()));
assert_eq!(result.get("CFG_CMD"), Some(&"echo 'test'".to_string()));
}
}
+11
View File
@@ -0,0 +1,11 @@
pub mod hook;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum FinalizeStage {
Register,
EarlyHook,
Artifact,
Publish,
Notify,
LateHook,
Clean,
}
+69
View File
@@ -0,0 +1,69 @@
use crate::ExecutionContext;
use crate::engine::types::DeltaExecutionContext;
use crate::engine::{Engine, EventSender, ExecutionError};
use crate::finalize::plugin::{PluginMap, call_named_plugin};
use crate::types::command::CustomCommand;
use std::path::PathBuf;
pub async fn hook(
hook: Option<&Vec<CustomCommand>>,
ctx: &ExecutionContext,
event_tx: EventSender,
plugins: &PluginMap,
) -> Result<(), ExecutionError> {
let hook = match hook {
Some(h) => h,
None => {
return Ok(());
}
};
let engine = Engine::new();
for (index, hook_cmd) in hook.iter().enumerate() {
if hook_cmd.command.starts_with("plugin:") {
// 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() {
match i.1 {
Some(v) => {
plugin_ctx.env_vars.insert(i.0, v);
}
None => {
plugin_ctx.env_vars.remove(&i.0);
}
}
}
let plugin_name = hook_cmd.command.strip_prefix("plugin:").expect(
"Internal error: command starts with \"plugin:\" but failed to strip prefix",
);
log::info!("Calling plugin hook {}: {}", index, plugin_name);
call_named_plugin(
plugins,
plugin_name,
hook_cmd.argument.clone(),
&plugin_ctx,
&event_tx,
).await.map_err(|e| {
log::error!("Plugin hook {} failed: {:?}", index, e);
ExecutionError::ExecutionFailed(e.to_string())
})?;
continue;
}
let deltactx = DeltaExecutionContext {
working_dir: hook_cmd.working_dir.as_ref().map(PathBuf::from),
env_vars: hook_cmd.environment.clone().unwrap_or_default(),
timeout: Some(std::time::Duration::from_millis(hook_cmd.timeout_ms)),
};
log::info!("Executing hook #{}: {}.", index, hook_cmd.name);
let result = engine
.execute_command_with_delta(&hook_cmd.command, ctx, &event_tx, &deltactx)
.await;
if let Err(e) = result {
log::error!("Hook {} failed: {:?}", index, e);
return Err(e);
}
}
Ok(())
}
+11
View File
@@ -0,0 +1,11 @@
// WIP: Template resolution will be implemented as a shared module
// used by both prebake and finalize stages.
// See: workshop-baker/src/template/ (planned)
//
// Variable namespaces:
// {{ xxx }} - User config values (nested: dockerhub.user)
// {{ secret.xxx }} - Vault secrets
// {{ build.status }} - Build metadata
// {{ build.duration }} - Build duration in seconds
// {{ build.stage }} - Current/last stage
// {{ env.xxx }} - Environment variables
+102
View File
@@ -0,0 +1,102 @@
// Library crate for workshop-executor
// Selective exports: only modules used by external callers or tests
pub mod daemon;
pub mod engine;
pub mod error;
pub mod finalize;
pub mod monitor;
pub mod prebake;
pub mod bake;
pub mod socket;
pub mod types;
pub mod constant;
pub mod notify;
// External crates re-exports
pub use config;
// Re-export commonly used types
pub use engine::{Engine, EventSender, ExecutionContext, ExecutionError, ExecutionResult};
// CLI types used by main (keep here since they're used in daemon)
pub use clap::{Parser, Subcommand};
pub use serde::Deserialize;
pub use std::collections::HashMap;
pub use std::path::PathBuf;
pub mod cli {
pub use super::{Deserialize, HashMap, Parser, PathBuf, Subcommand};
pub use clap::Command;
/// Honey Biscuit Workshop script executor
#[derive(Parser, Debug)]
#[command(version, about, long_about=None)]
pub struct Cli {
/// Username of the pipeline executor
#[arg(short, long)]
pub username: String,
/// Pipeline ID of this pipeline
#[arg(short, long)]
pub pipeline: String,
/// Build ID of this build
#[arg(short, long)]
pub build_id: String,
/// Parse the script without executing
#[arg(long, default_value = "false")]
pub dry_run: bool,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Monitor a running task
Monitor { group: String },
/// Prepare a task
Prebake { config: PathBuf },
/// Run in client mode
Client { config: String },
/// Run a task
Bake {
/// Path to the script to execute
script: PathBuf,
/// Path to bake_base.sh
#[arg(short, long, default_value = "./bake_base.sh")]
bake_base: PathBuf,
#[arg(short, long, default_value = "./prebake.yml")]
prebake: PathBuf,
},
/// Finish a task
Finalize { config: String },
}
#[derive(Debug, Deserialize)]
pub struct TaskSpec {
pub subcommand: String,
pub arguments: Vec<String>,
pub options: HashMap<String, String>,
}
impl TaskSpec {
pub fn command(&self, executable: &PathBuf) -> tokio::process::Command {
let mut command = tokio::process::Command::new(executable);
command.arg(&self.subcommand);
for arg in &self.arguments {
command.arg(arg);
}
for (flag, value) in self.options.iter() {
if value.is_empty() {
command.arg(format!("--{}", flag));
} else {
command.arg(format!("--{}={}", flag, value));
}
}
command
}
}
}
+122
View File
@@ -0,0 +1,122 @@
use std::path::Path;
use env_logger;
use workshop_baker::{
cli::{Cli, Commands, Parser},
daemon,
engine::{EventSender, ExecutionContext},
error::PrebakeError,
prebake::prebake,
bake::bake,
finalize::finalize,
};
#[tokio::main]
async fn main() {
env_logger::init();
let cli = Cli::parse();
if cli.dry_run {
log::warn!("Dry run enabled, no changes will be made.");
}
match &cli.command {
Some(Commands::Monitor { group: _ }) => {
unimplemented!("executor-monitor should be moved to daemon instead.")
}
Some(Commands::Client { config:_ }) => {
unimplemented!("Client mode not yet implemented.")
}
Some(Commands::Prebake { config }) => {
log::info!("Running in standalone mode.");
let mut ctx = ExecutionContext {
standalone: true,
task_id: format!("{}-{}-init", cli.pipeline, cli.build_id),
pipeline_name: cli.pipeline.clone(),
username: cli.username.clone(),
dry_run: cli.dry_run,
privileged: true,
..Default::default()
};
let (tx, event_rx) = tokio::sync::mpsc::channel(256);
let event_tx: EventSender = Some(tx);
let pipeline_name = cli.pipeline.clone();
let build_id = cli.build_id.clone();
let receiver_handle = tokio::spawn(
workshop_baker::prebake::event::event_receiver(event_rx, pipeline_name, build_id)
);
let result = prebake(config, &cli, None, &mut ctx, event_tx).await;
drop(ctx);
let _ = receiver_handle.await;
dbg!(&result);
if let Err(ref e) = result {
log::error!("Prebake stage failed: {}", e);
std::process::exit(
e.downcast_ref::<PrebakeError>()
.map(|pe| pe.exit_code())
.unwrap_or(1)
);
}
}
Some(Commands::Bake {
script, bake_base, prebake
}) => {
log::info!("Running in standalone mode.");
let mut ctx = ExecutionContext {
standalone: true,
task_id: format!("{}-{}-bake", cli.pipeline, cli.build_id),
pipeline_name: cli.pipeline.clone(),
username: cli.username.clone(),
dry_run: cli.dry_run,
privileged: false,
..Default::default()
};
let (tx, event_rx) = tokio::sync::mpsc::channel(256);
let event_tx: EventSender = Some(tx);
let pipeline_name = cli.pipeline.clone();
let build_id = cli.build_id.clone();
let receiver_handle = tokio::spawn(
workshop_baker::prebake::event::event_receiver(event_rx, pipeline_name, build_id)
);
let result = bake(script, bake_base, prebake, &cli, &mut ctx, event_tx).await;
drop(ctx);
let _ = receiver_handle.await;
dbg!(&result);
if result.is_err() {
log::error!("Bake stage failed! Exiting...");
std::process::exit(1);
}
}
Some(Commands::Finalize { config }) => {
log::info!("Running in standalone mode.");
let mut ctx = ExecutionContext {
standalone: true,
task_id: format!("{}-{}-finalize", cli.pipeline, cli.build_id),
pipeline_name: cli.pipeline.clone(),
username: cli.username.clone(),
dry_run: cli.dry_run,
privileged: false,
..Default::default()
};
let (tx, event_rx) = tokio::sync::mpsc::channel(256);
let event_tx: EventSender = Some(tx);
let pipeline_name = cli.pipeline.clone();
let build_id = cli.build_id.clone();
let receiver_handle = tokio::spawn(
workshop_baker::prebake::event::event_receiver(event_rx, pipeline_name, build_id)
);
let result = finalize(Path::new(config), &cli, &mut ctx, event_tx).await;
drop(ctx);
let _ = receiver_handle.await;
dbg!(&result);
if let Err(ref e) = result {
log::error!("Finalize stage failed: {}", e);
std::process::exit(1);
}
}
None => {
// Running in daemon mode
log::info!("Executor will be running in daemon mode.");
let _result = daemon::daemon(&cli).await;
}
}
}
+89
View File
@@ -0,0 +1,89 @@
mod cgroups;
mod jobobject;
use crate::socket::{establish_connection, get_socket_addr};
use config::Config;
use serde::{Deserialize, Serialize};
use std::time::Instant;
use tokio::io::AsyncWriteExt;
#[derive(Debug, Serialize, Deserialize)]
pub struct ResourceUsageInfo {
pub msgtype: String,
pub name: String,
pub os_type: OsType,
pub statistics: UsageStats,
pub timestamp: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum OsType {
Linux,
Windows,
MacOS,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UsageStats {
pub cpu_usage_percent: f64,
pub memory_usage_bytes: u64,
pub memory_limit_bytes: i64,
// pub memory_usage_percent: f64,
pub process_count: u64,
pub cpu_usage_us: u64,
/// Internal timestamp, using std::Instant
#[serde(skip, default = "default_internal_timestamp")]
_timestamp: Instant,
}
fn default_internal_timestamp() -> Instant {
Instant::now()
}
pub async fn monitor(
name: &String,
interval_ms: Option<u64>,
settings: Config,
) -> anyhow::Result<()> {
// let socket_addr = get_wsagent_socket_addr()?;
let socket_addr = get_socket_addr(settings.get_string("socket")?)?;
let interval = tokio::time::Duration::from_millis(interval_ms.unwrap_or(1000));
let mut socket = establish_connection(&socket_addr).await?;
let mut prev_usage = UsageStats {
cpu_usage_percent: 0.0,
memory_usage_bytes: 0,
memory_limit_bytes: 0,
process_count: 0,
cpu_usage_us: 0,
_timestamp: default_internal_timestamp(),
};
loop {
let usage: UsageStats = cgroups::get_usage(name, prev_usage)?; // TODO: 多系统支持?
prev_usage = usage.clone();
// dbg!(&usage);
let usage_info = ResourceUsageInfo {
msgtype: "ResourceUsage".to_string(),
name: name.clone(),
os_type: OsType::Linux, // TODO: 多系统支持?
statistics: usage,
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64,
};
let usage_json = serde_json::to_string(&usage_info)?;
send_usage_info(&mut socket, &usage_json).await?;
// TODO: 增加消息接收,提供(伪)双向通信功能
tokio::time::sleep(interval).await;
}
}
async fn send_usage_info<S>(stream: &mut S, usage_json: &str) -> anyhow::Result<()>
where
S: tokio::io::AsyncWrite + Unpin,
{
stream.write_all(usage_json.as_bytes()).await?;
stream.write_all(b"\n").await?;
Ok(())
}
+118
View File
@@ -0,0 +1,118 @@
use crate::monitor::UsageStats;
use cgroups_rs;
use cgroups_rs::fs::Cgroup;
use cgroups_rs::fs::{cpu::CpuController, memory::MemController, pid::PidController};
use log;
use std::time::{Duration, Instant};
#[cfg(target_os = "linux")]
pub fn get_usage(
name: &String,
usage_prev: crate::monitor::UsageStats,
) -> anyhow::Result<crate::monitor::UsageStats> {
let hierarchy = cgroups_rs::fs::hierarchies::auto();
if !hierarchy.v2() {
return Err(anyhow::anyhow!(
"Unsupported cgroup hierarchy, cgroups v2 required."
));
}
let cgroup = Cgroup::load(hierarchy, name);
let mut cpu_usage_us: u64 = 0;
let mut cpu_usage_percent: f64 = 0.0;
let mut memory_usage_bytes: u64 = 0;
let mut memory_limit_bytes: i64 = 0;
let mut process_count: u64 = 0;
if let Some(cpu) = cgroup.controller_of::<CpuController>() {
let stats = parse_cpustat(cpu.cpu().stat.as_str())?;
cpu_usage_us = stats.usage_usec;
} else {
log::warn!("Failed to fetch CPU usage info!");
}
let current = Instant::now();
let delta_usage = cpu_usage_us - usage_prev.cpu_usage_us;
let delta_wall = current - usage_prev._timestamp;
if usage_prev._timestamp > current {
log::warn!("Usage stats are from the future!");
} else if usage_prev.cpu_usage_us == 0 {
log::info!("Previous CPU usage is zero, probably a fresh start.");
} else if delta_wall < Duration::from_millis(1) {
log::warn!("Usage stats are too close together!");
} else {
cpu_usage_percent = delta_usage as f64 / delta_wall.as_micros() as f64 * 100.0;
// dbg!(&delta_usage, &delta_wall);
}
if let Some(mem) = cgroup.controller_of::<MemController>() {
let stats = mem.memory_stat();
memory_usage_bytes = stats.usage_in_bytes;
memory_limit_bytes = stats.limit_in_bytes;
}
if let Some(proc) = cgroup.controller_of::<PidController>() {
process_count = proc.get_pid_current()?;
}
Ok(UsageStats {
cpu_usage_us,
cpu_usage_percent,
memory_usage_bytes,
// memory_usage_percent: 0.0,
memory_limit_bytes,
process_count,
_timestamp: Instant::now(),
})
// todo!();
}
#[derive(Debug, Default, Clone)]
pub struct CgroupCpuStat {
pub usage_usec: u64,
pub user_usec: u64,
pub system_usec: u64,
pub nice_usec: u64,
pub core_sched_force_idle_usec: u64,
pub nr_periods: u64,
pub nr_throttled: u64,
pub throttled_usec: u64,
pub nr_bursts: u64,
pub burst_usec: u64,
}
fn parse_cpustat(stat: &str) -> anyhow::Result<CgroupCpuStat> {
let mut cpu_stat = CgroupCpuStat::default();
for line in stat.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let (key, value_str) = line
.split_once(' ')
.ok_or_else(|| anyhow::anyhow!("Invalid format:{}", line))?;
let value = value_str.parse::<u64>().map_err(|e| {
anyhow::anyhow!(
"Cannot convert value {}({}) into integer: {}",
key,
value_str,
e
)
})?;
match key {
"usage_usec" => cpu_stat.usage_usec = value,
"user_usec" => cpu_stat.user_usec = value,
"system_usec" => cpu_stat.system_usec = value,
"nice_usec" => cpu_stat.nice_usec = value,
"core_sched.force_idle_usec" => cpu_stat.core_sched_force_idle_usec = value,
"nr_periods" => cpu_stat.nr_periods = value,
"nr_throttled" => cpu_stat.nr_throttled = value,
"throttled_usec" => cpu_stat.throttled_usec = value,
"nr_bursts" => cpu_stat.nr_bursts = value,
"burst_usec" => cpu_stat.burst_usec = value,
_unknown_key => {} // Ignore unknown keys
}
}
Ok(cpu_stat)
}
+4
View File
@@ -0,0 +1,4 @@
#[cfg(target_os = "windows")]
pub fn get_usage() -> anyhow::Result<f64> {
unimplemented!("Not supporting windows JobObject at this time!");
}
+203
View File
@@ -0,0 +1,203 @@
use crate::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::FinalizePlugin;
use crate::finalize::plugin::fetch;
use crate::types::buildstatus::StagePhase;
use crate::types::buildstatus::StageResult;
use crate::{EventSender, ExecutionContext};
use chrono::{DateTime, Utc};
use serde::Serialize;
use std::collections::HashMap;
use std::collections::HashSet;
use std::hash::Hash;
use std::path::Path;
use std::path::PathBuf;
use uuid::Uuid;
mod error;
use error::NotifyError;
fn validate(finalize: &FinalizeConfig) -> anyhow::Result<()> {
let validate_result = finalize.validate();
if let Err(errors) = validate_result {
for error in &errors {
log::error!("Error: {}", error);
}
anyhow::bail!(NotifyError::FinalizeValidationError(errors))
};
Ok(())
}
pub type NotifyPluginMap = HashMap<String, FinalizePlugin>;
fn extract_plugin(finalize: &FinalizeConfig) -> HashSet<String> {
finalize
.notification
.groups
.iter()
.flat_map(|(_, methods)| methods.keys())
.cloned()
.collect()
}
#[derive(Debug, Clone, Serialize)]
pub struct NotifyEvent {
id: Uuid,
stage: StagePhase,
substage: String,
result: StageResult,
not_before: DateTime<Utc>,
priority: u32,
effective_priority: u32,
lives: u32,
max_lives: u32,
target: HashSet<String>, // Target audience
}
impl Hash for NotifyEvent {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
impl PartialEq for NotifyEvent {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for NotifyEvent {}
impl PartialOrd for NotifyEvent {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(
self.effective_priority
.cmp(&other.effective_priority)
.then(self.not_before.cmp(&other.not_before))
.reverse(),
)
}
}
impl Ord for NotifyEvent {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.partial_cmp(other).unwrap()
}
}
type BundledNotifyEvent = Vec<NotifyEvent>;
type NotifyEventReceiver = tokio::sync::mpsc::Receiver<BundledNotifyEvent>;
pub type NotifyEventSender = tokio::sync::mpsc::Sender<NotifyEvent>;
const NOTIFY_NOTREADY_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
const NOTIFY_NOTREADY_PRIORITY: u32 = 99;
const NOTIFY_TEMPORARY_DELAY_FACTOR: f64 = 2.0;
const NOTIFY_TEMPORARY_DELAY_TIME: f64 = 5.0;
const NOTIFY_TEMPORARY_MAX_TIME: f64 = f64::MAX;
pub async fn notify(
finalize_path: &Path,
ctx: &mut ExecutionContext,
event_tx: EventSender,
notifyevent_rx: &mut NotifyEventReceiver,
notifyevent_tx: NotifyEventSender,
) -> anyhow::Result<()> {
let mut finalize = parse(finalize_path)?;
validate(&finalize)?;
let plugin_list = extract_plugin(&finalize);
if plugin_list.is_empty() {
log::warn!("No notification method provided, skipping notification");
// TODO: send event to event_tx
return Ok(());
}
if ctx.standalone {
fetch::fetch_plugins(
&PathBuf::from(FINALIZE_STANDALONE_PLUGIN_PATH),
&mut finalize.plugin,
Some(&plugin_list),
)
.await?;
}
let notify_plugins = plugin::register(finalize.plugin, Some(&plugin_list))?;
loop {
tokio::select! {
Some(events) = notifyevent_rx.recv() => {
match notify_handler(&notify_plugins, &finalize.notification, &events){
Ok(_) => {}, // Notification succeeded / failed with fallible
Err(e) => {
log::debug!("Failed to send notification: {:?}", e);
match e {
NotifyError::PluginNotAvailable(_) => {
// Should keep lives, add static delay and give less priority
for mut event in events {
event.not_before = Utc::now() + NOTIFY_NOTREADY_DELAY;
event.effective_priority = event.effective_priority.saturating_add(1);
notifyevent_tx.send(event).await?;
}
},
NotifyError::TemporaryError(_) => {
// Should cost 1 life, add dynamic (exponential) delay
for mut event in events {
event.lives -= 1;
event.not_before = Utc::now() + notify_delay(&event);
notifyevent_tx.send(event).await?;
}
},
NotifyError::PermanentError(_) => {
// Unexpected!
// Should be dropped
log::error!("None of the configured notification plugins works.");
log::error!("Message will be dropped.");
// TODO: Send audit/metric information
}
error => {
// Unexpected!
log::error!("Internal error: notify_handler returned unexpected error: {:?}", error);
}
};
}
};
}
}
}
}
fn notify_handler(
plugins: &NotifyPluginMap,
config: &NotificationConfig,
event: &Vec<NotifyEvent>,
) -> Result<(), NotifyError> {
todo!();
}
// 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 {
if count == 0 {
log::warn!("Unexpected notification count: {}", count);
return group_period as f32;
}
f32::min(1.0, group_watermark as f32 / count as f32) * group_period as f32
}
// calculate p^(m-l)*t as notification delay
fn notify_delay(event: &NotifyEvent) -> std::time::Duration {
let exponent = (event.max_lives - event.lives) as i32;
if exponent < 0 {
log::warn!("Unexpected lives in notification: lives({}) > max_lives({})", event.lives, event.max_lives);
return std::time::Duration::from_secs_f64(NOTIFY_TEMPORARY_MAX_TIME);
}
let multiplier = NOTIFY_TEMPORARY_DELAY_FACTOR.powi(exponent);
let delay = multiplier * NOTIFY_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);
}
std::time::Duration::from_secs_f64(delay)
}
+19
View File
@@ -0,0 +1,19 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum NotifyError{
#[error("Finalize config validation failed: {0:?}")]
FinalizeValidationError(Vec<String>),
#[error("Notification plugin not available {0}")]
PluginNotAvailable(String),
#[error("Temporary failure: {0}")]
TemporaryError(String),
#[error("Permanent error: {0}")]
PermanentError(String),
#[error("Notification failed after {0} attempt(s)")]
Exhausted(u32),
}
+320
View File
@@ -0,0 +1,320 @@
//! Prebake workflow orchestration module.
//!
//! This module provides the main prebake workflow execution logic, orchestrating
//! the various stages of build environment preparation.
//!
//! # Stages
//!
//! The prebake workflow executes the following stages in order:
//!
//! 1. **Bootstrap**: Sets up the build environment (user, workspace, privileges)
//! 2. **EarlyHook**: Executes user-defined commands before dependency installation
//! 3. **DepsSystem**: Installs system-level package dependencies
//! 4. **DepsUser**: Installs user-level dependencies
//! 5. **LateHook**: Executes user-defined commands after dependency installation
//! 6. **Ready**: Signals completion of the prebake workflow
//!
//! # Usage
//!
//! ```ignore
//! let result = prebake("/path/to/prebake.yml", &cli, None).await;
//! ```
pub mod config;
pub mod env;
pub mod event;
pub mod security;
pub mod stage;
use crate::{
cli::Cli,
engine::EventSender,
error::PrebakeError,
ExecutionContext,
prebake::{security::get_drop_after, stage::PrebakeStage},
types::buildstatus::{write_stage_status, StageInfo, StagePhase, StageResult},
constant::STANDALONE_STATUS_DIR,
};
use std::path::Path;
use chrono::Utc;
pub use config::PrebakeConfig;
pub use security::prebake_drop_privilege;
/// Parses a YAML configuration string into a `PrebakeConfig`.
///
/// # Arguments
///
/// * `yaml_content` - A string containing YAML configuration
///
/// # Returns
///
/// Returns `Ok(PrebakeConfig)` on successful parsing, or `Err(serde_yaml::Error)`
/// if the YAML is invalid.
pub fn parse(yaml_content: &str) -> Result<PrebakeConfig, serde_yaml::Error> {
serde_yaml::from_str(yaml_content)
}
// pub fn serialize_prebake_config(config: &PrebakeConfig) -> Result<String, serde_yaml::Error> {
// serde_yaml::to_string(config)
// }
/// Executes the complete prebake workflow.
///
/// This function orchestrates the entire prebake process, including:
/// - Configuration loading and validation
/// - Privilege dropping based on security settings
/// - Sequential execution of all prebake stages
///
/// # Arguments
///
/// * `prebake_path` - Path to the prebake.yml configuration file
/// * `cli` - CLI arguments containing pipeline, build_id, username, and dry_run flag
/// * `stage` - Optional specific stage to start from (defaults to Bootstrap)
///
/// # Stage Execution
///
/// The function executes stages sequentially based on the `stage` parameter:
/// - If `stage` is `None`, starts from Bootstrap
/// - If `stage` is `Some`, starts from the specified stage
///
/// Each stage may drop privileges before execution according to the security configuration.
///
/// # Returns
///
/// Returns `Ok(())` on successful completion of all stages, or `anyhow::Result<()>` if:
/// - Configuration file cannot be read
/// - YAML parsing fails
/// - Configuration validation fails
/// - Any stage execution fails
pub async fn prebake(
prebake_path: &Path,
cli: &Cli,
stage: Option<PrebakeStage>,
mut ctx: &mut ExecutionContext,
event_tx: EventSender,
) -> anyhow::Result<()> {
// Initialize
let prebake_content = std::fs::read_to_string(prebake_path).inspect_err(|e| {
log::error!("Failed to read prebake.yml: {}", e);
})?;
let prebake = parse(prebake_content.as_str()).map_err(|e| {
log::error!("Failed to parse prebake.yml: {}", e);
PrebakeError::YamlParseError(e)
})?;
let result = prebake.validate();
if let Err(errors) = result {
for error in errors {
log::error!("Error: {}", error);
}
anyhow::bail!(PrebakeError::ValidateError());
}
let dropafter = get_drop_after(&prebake.security).map_err(|e| {
log::error!("Failed to parse drop-after: {}", e);
PrebakeError::ConfigError(e)
})?;
let target_user = "vulcan";
// Wait for bakerd to finish Fetch-1
let stage = stage.unwrap_or(PrebakeStage::Bootstrap);
if let Some(env) = &prebake.envvars
&& let Some(prebake) = &env.prebake
{
ctx.env_vars.extend(prebake.clone());
}
// Bootstrap stage
if stage <= PrebakeStage::Bootstrap {
let stage_start = Utc::now();
log::info!("Running PBStage: Bootstrap");
prebake_drop_privilege(PrebakeStage::Bootstrap, dropafter, target_user, &mut ctx)?;
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?;
}
// EarlyHook stage
if stage <= PrebakeStage::EarlyHook {
let stage_start = Utc::now();
log::info!("Running PBStage: EarlyHook");
prebake_drop_privilege(PrebakeStage::EarlyHook, dropafter, target_user, &mut ctx)?;
ctx.task_id = format!("{}-{}-earlyhook", cli.pipeline, cli.build_id);
let earlyhook = match &prebake.hooks {
Some(hooks) => hooks.early.clone(),
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?;
}
if let Some(dependencies) = prebake.dependencies {
let endpoint = dependencies
.config
.as_ref()
.map(|c| c.repology_endpoint)
.unwrap_or(crate::types::repology::RepologyEndpoint::Default);
let osinfo = os_info::get();
let detected_pm = crate::engine::pm::detect(&osinfo).map(|pm| pm.name().to_string());
let upm_sys_deps = if let Some(user) = &dependencies.user {
if stage <= PrebakeStage::DepsSystem {
stage::depsuser::collect_upm_sysdeps(user, &ctx, &endpoint).await?
} else {
stage::depsuser::CollectedUPMdeps::default()
}
} else {
stage::depsuser::CollectedUPMdeps::default()
};
let mut system_config = dependencies.system.clone().unwrap_or_default();
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(
&sys_deps.packages,
sys_deps.mapped,
&endpoint,
pm_name,
)
.await
.map_err(|e| anyhow::anyhow!("Repology error: {:?}", e))?;
if !resolved.is_empty() {
log::info!(
"UPM {} resolved to system packages: {:?}",
upm_name,
resolved
);
}
for (target_pm, packages) in &resolved {
system_config
.entry(target_pm.clone())
.or_insert_with(|| config::SystemDependency {
packages: Vec::new(),
repositories: None,
mirror: None,
})
.packages
.extend(packages.iter().cloned());
}
}
} else {
log::warn!("No package manager detected, cannot resolve UPM system dependencies");
}
}
if !system_config.is_empty() && stage <= PrebakeStage::DepsSystem {
let stage_start = Utc::now();
log::info!("Running PBStage: DepsSystem");
prebake_drop_privilege(PrebakeStage::DepsSystem, dropafter, target_user, &mut ctx)?;
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?;
}
if let Some(user) = dependencies.user
&& stage <= PrebakeStage::DepsUser
{
let stage_start = Utc::now();
log::info!("Running PBStage: DepsUser");
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?;
}
}
// LateHook stage
if stage <= PrebakeStage::LateHook {
let stage_start = Utc::now();
log::info!("Running PBStage: LateHook");
prebake_drop_privilege(PrebakeStage::LateHook, dropafter, target_user, &mut ctx)?;
ctx.task_id = format!("{}-{}-latehook", cli.pipeline, cli.build_id);
let latehook = match &prebake.hooks {
Some(hooks) => hooks.late.clone(),
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?;
}
// Ready stage
ctx.task_id = format!("{}-{}-ready", cli.pipeline, cli.build_id);
log::info!("Prebake done!");
Ok(())
}
+51
View File
@@ -0,0 +1,51 @@
# workshop-baker/src/prebake
**Generated:** 2026-04-22
**Commit:** 7b3b71a
Build environment setup: bootstrap, dependencies, security, hooks.
## STRUCTURE
```
prebake/
├── config.rs # PrebakeConfig YAML schema (environment, deps, cache)
├── stage.rs # PrebakeStage enum (Bootstrap→Ready ordering)
├── stage/
│ ├── bootstrap.rs # User creation (vulcan), sudo/doas setup
│ ├── environment.rs # Env var injection
│ ├── depssystem.rs # System package installation
│ ├── depsuser.rs # User package installation (cargo, npm, etc.)
│ └── hook.rs # Hook execution (pre/post stage)
├── env/ # Environment providers
│ ├── mod.rs # EnvProvider trait
│ ├── container.rs # Docker/container support
│ ├── firecracker.rs # Firecracker microVM
│ ├── baremetal.rs # Direct host execution
│ └── custom.rs # Custom env (todo!())
├── security.rs # Privilege dropping (unsafe libc calls)
└── event.rs # Event logging
```
## WHERE TO LOOK
| Task | Location |
|------|----------|
| Config loading | `config.rs:PrebakeConfig::from_yaml()` |
| Stage ordering | `stage.rs:PrebakeStage` enum |
| User setup | `stage/bootstrap.rs` - vulcan user creation |
| Security | `security.rs:72` - privilege dropping |
| Hooks | `stage/hook.rs` - pre/post stage hooks |
## CONVENTIONS
- **PrebakeStage**: Ordered enum (Bootstrap→DepsSystem→DepsUser→Environment→Hooks→Ready)
- **ExecutionContext**: Carries task_id, username, env_vars through all stages
- **Target User**: "vulcan" (hardcoded in bootstrap.rs:6-24)
## ANTI-PATTERNS
1. **Hardcoded user** — "vulcan" username hardcoded throughout
2. **Unsafe blocks**`security.rs` has 5+ unsafe libc calls
3. **Incomplete** — custom.rs and apt.rs have `todo!()` stubs
4. **doas.conf** — bootstrap.rs:183: "We do not know how to write doas.conf lol"
+319
View File
@@ -0,0 +1,319 @@
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use serde_yaml::Value;
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use crate::types::architecture::Architecture;
use crate::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;
pub const VERSION_REQUIREMENT: &str = "^1.0";
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct PrebakeConfig {
pub version: String,
pub environment: Environment,
#[serde(default)]
pub bootstrap: Option<Bootstrap>,
#[serde(default)]
pub dependencies: Option<Dependencies>,
#[serde(default)]
pub envvars: Option<EnvVars>,
#[serde(default)]
pub cache: Option<Cache>,
#[serde(default)]
pub security: Option<Security>,
#[serde(default)]
pub hooks: Option<Hooks>,
#[serde(default)]
pub metadata: Option<Metadata>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Bootstrap {
#[serde(default = "default_bootstrap_user")]
pub user: String,
#[serde(default)]
pub workspace: Option<Workspace>,
#[serde(default)]
pub sudoers: Option<String>,
#[serde(default)]
pub doas: Option<String>,
#[serde(default)]
pub custom: Option<String>,
}
impl Default for Bootstrap {
fn default() -> Self {
Self {
user: default_bootstrap_user(),
workspace: None,
sudoers: None,
doas: None,
custom: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Workspace {
#[serde(default = "default_workspace")]
pub path: String,
#[serde(default = "default_true")]
pub fallback: bool,
}
impl Default for Workspace {
fn default() -> Self {
Self {
path: default_workspace(),
fallback: default_true(),
}
}
}
fn default_workspace() -> String {
"/home/vulcan/workspace".to_string()
}
fn default_bootstrap_user() -> String {
"vulcan".to_string()
}
fn default_true() -> bool {
true
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Environment {
pub builder: BuilderType,
#[serde(default)]
pub baremetal: Option<BaremetalConfig>,
#[serde(default)]
pub docker: Option<DockerConfig>,
#[serde(default)]
pub firecracker: Option<FirecrackerConfig>,
#[serde(default)]
pub custom: Option<CustomConfig>,
#[serde(default)]
pub resources: Option<ResourceRequirements>,
#[serde(default)]
pub network: Option<NetworkConfig>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ResourceRequirements {
#[serde(default = "default_cpu")]
pub cpu: u32,
#[serde(default = "default_memory")]
pub memory: MemSize,
#[serde(default = "default_disk")]
pub disk: MemSize,
#[serde(
default,
deserialize_with = "parse_architecture",
serialize_with = "serialize_architecture"
)]
pub architecture: HashSet<Architecture>,
// TODO: Design GPU config
// #[serde(default = "default_gpu")]
// pub gpu: bool,
// #[serde(default)]
// pub gpu_type: Option<Vec<GpuType>>,
#[serde(default)]
pub tags: Option<Vec<String>>,
}
fn default_cpu() -> u32 {
1
}
fn default_memory() -> MemSize {
MemSize::from_gib(1)
}
fn default_disk() -> MemSize {
MemSize::from_gib(1)
}
fn parse_architecture<'de, D>(deserializer: D) -> Result<HashSet<Architecture>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{Error, Visitor};
use std::fmt;
struct ArchVisitor;
impl<'de> Visitor<'de> for ArchVisitor {
type Value = HashSet<Architecture>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string or an array of strings")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: Error,
{
let arch = Architecture::from_str(v).map_err(E::custom)?;
let mut set = HashSet::new();
set.insert(arch);
Ok(set)
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut set = HashSet::new();
while let Some(s) = seq.next_element::<String>()? {
let arch = Architecture::from_str(&s).map_err(A::Error::custom)?;
set.insert(arch);
}
Ok(set)
}
}
deserializer.deserialize_any(ArchVisitor)
}
fn serialize_architecture<S>(
value: &HashSet<Architecture>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(Some(value.len()))?;
for arch in value {
seq.serialize_element(arch.as_str())?;
}
seq.end()
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct NetworkConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_outbound")]
pub outbound: bool,
#[serde(default)]
pub dns_servers: Option<Vec<String>>,
#[serde(default)]
pub proxies: Option<Vec<ProxyConfig>>,
}
fn default_enabled() -> bool {
true
}
fn default_outbound() -> bool {
true
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ProxyConfig {}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Dependencies {
#[serde(default)]
pub system: Option<HashMap<String, SystemDependency>>,
#[serde(default)]
pub user: Option<HashMap<String, Value>>,
#[serde(default)]
pub config: Option<DependenciesConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DependenciesConfig {
#[serde(default)]
pub repology_endpoint: RepologyEndpoint,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct SystemDependency {
pub packages: Vec<String>,
#[serde(default)]
pub repositories: Option<Value>,
#[serde(default)]
pub mirror: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct EnvVars {
#[serde(default)]
pub prebake: Option<HashMap<String, String>>,
#[serde(default)]
pub bake: Option<HashMap<String, String>>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Cache {
pub directory: Vec<CacheDirectory>,
#[serde(default)]
pub strategy: Option<CacheStrategy>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Security {
#[serde(default)]
pub drop_after: Option<String>,
} // TODO: More security module
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Hooks {
#[serde(default)]
pub early: Option<Vec<CustomCommand>>,
#[serde(default)]
pub late: Option<Vec<CustomCommand>>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Metadata {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub maintainer: Option<String>,
}
impl PrebakeConfig {
pub fn validate(&self) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
let version_requirement =
VersionReq::parse(VERSION_REQUIREMENT).expect("Invalid version requirement");
let version = &self.version;
let version = match version.matches('.').count() {
0 => format!("{}.0.0", version),
1 => format!("{}.0", version),
_ => version.clone(),
};
match Version::parse(&version) {
Ok(version) if version_requirement.matches(&version) => {}
Ok(version) => errors.push(format!(
"Version {} does not satisfy requirement {}",
version, VERSION_REQUIREMENT
)),
Err(e) => errors.push(format!("Invalid version format: {}", e)),
}
// TODO: Schema based validation
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
+8
View File
@@ -0,0 +1,8 @@
use crate::prebake::config::PrebakeConfig;
use config::Config;
use log::info;
pub async fn prepare_baremetal(_config: &PrebakeConfig, _settings: &Config) -> anyhow::Result<()> {
info!("Nothing to be done for baremetal environment.");
Ok(())
}
+182
View File
@@ -0,0 +1,182 @@
use crate::prebake::config::PrebakeConfig;
use crate::types::builderconfig::DockerConfig;
use anyhow::Context;
use bollard::{
API_DEFAULT_VERSION, Docker, body_full,
query_parameters::{BuildImageOptionsBuilder, CreateImageOptionsBuilder},
secret::{BuildInfo, CreateImageInfo},
};
use config::Config;
use futures_util::stream::StreamExt;
use log::{debug, error, info, warn};
use std::fs;
use tar;
use uuid::Uuid;
pub async fn prepare_container(
config: &PrebakeConfig,
settings: &Config,
) -> anyhow::Result<String> {
let container_options = match config.environment.docker.as_ref() {
Some(docker) => docker,
None => {
return Err(anyhow::anyhow!("Missing Docker configuration"));
}
};
info!("Connecting to container engine");
let container_url = settings
.get_string("container.url")
.unwrap_or_else(|_| "__DEFAULT".to_string());
let container_url = container_url.as_str();
let container_connection = if container_url.starts_with("unix://") {
Docker::connect_with_socket(container_url, 20, API_DEFAULT_VERSION)?
} else if container_url.starts_with("http://") {
warn!("It is suggested to run WS-Executor and Docker/Podman on the same machine.");
Docker::connect_with_http(container_url, 20, API_DEFAULT_VERSION)?
} else if container_url.starts_with("ssh://") {
warn!("It is suggested to run WS-Executor and Docker/Podman on the same machine.");
Docker::connect_with_ssh(container_url, 20, API_DEFAULT_VERSION, None)?
} else if container_url.contains("__DEFAULT") {
Docker::connect_with_local_defaults()?
} else {
return Err(anyhow::anyhow!(
"Unsupported container URL scheme: {}",
container_url
));
};
if container_options.image.is_none() {
return container_build(container_options, container_connection, settings).await;
} else {
return container_pull(container_options, container_connection).await;
}
}
fn create_dockerfile_tar(dockerfile: &str) -> anyhow::Result<Vec<u8>> {
let mut tar_buffer = Vec::new();
let mut tar_builder = tar::Builder::new(&mut tar_buffer);
let dockerfile_content = fs::read_to_string(dockerfile)?;
let mut header = tar::Header::new_gnu();
header.set_path("Dockerfile")?;
header.set_size(dockerfile_content.len() as u64);
header.set_mode(0o644);
header.set_entry_type(tar::EntryType::Regular);
header.set_cksum();
tar_builder.append(&header, dockerfile_content.as_bytes())?;
tar_builder.finish()?;
tar_builder.into_inner()?;
Ok(tar_buffer)
}
async fn container_build(
container_options: &DockerConfig,
container_connection: Docker,
settings: &Config,
) -> anyhow::Result<String> {
info!("Building image at Prebake Stage 1...");
let dockerfile = container_options.dockerfile.as_ref().unwrap();
let tag = format!(
"HoneyBiscuitWorkshop/{}_{}",
settings.get_string("pipeline").unwrap().as_str(),
Uuid::new_v4()
);
let tar_buffer = match create_dockerfile_tar(dockerfile) {
Ok(buffer) => buffer,
Err(error) => {
if error.to_string().contains("No such file") {
error!("Couldn't find specified Dockerfile: {}", dockerfile);
} else {
error!("Failed to prepare Dockerfile: {}", error);
}
return Err(error);
}
};
let build_options = BuildImageOptionsBuilder::default()
.dockerfile("Dockerfile")
.t(&tag)
.build();
let mut build_result =
container_connection.build_image(build_options, None, Some(body_full(tar_buffer.into())));
while let Some(stream_item) = build_result.next().await {
let build_info = stream_item.context("Unexpected build result.");
debug!("{:?}", build_info);
match build_info {
Ok(BuildInfo {
id: _,
stream: _,
error_detail: _,
status: _,
progress_detail: _,
aux: _,
}) => {}
Err(error) => {
let error_text = error
.chain()
.map(|err| err.to_string())
.collect::<Vec<_>>()
.join(" ");
error!("Failed to build image: {}", error_text);
return Err(error);
}
}
}
info!("Successfully built the image {}", tag);
Ok(tag)
}
async fn container_pull(
container_options: &DockerConfig,
container_connection: Docker,
) -> anyhow::Result<String> {
let image_name = container_options
.image
.as_ref()
.expect("Unexpected empty image name");
info!("Pulling image: {}", image_name);
let pull_options = CreateImageOptionsBuilder::default()
.from_image(image_name)
.build();
let mut pull_result = container_connection.create_image(Some(pull_options), None, None);
while let Some(stream_item) = pull_result.next().await {
let pull_info = stream_item.context("Unexpected pull result.");
match pull_info {
Ok(CreateImageInfo {
id,
error_detail,
status,
progress_detail: _,
}) => {
if let Some(error_detail) = error_detail {
warn!("Error when pulling: {:?}", error_detail);
}
if let Some(status) = status {
if status.contains("Downloading") || status.contains("Extracting") {
} else if status.contains("Pull complete") {
info!("Layer complete: {}", id.unwrap_or_default());
} else if status.contains("Already exists") {
info!("Layer already exists: {}", id.unwrap_or_default());
} else if status.contains("Image is up to date") {
info!("Up to date image: {}", image_name);
} else if status.contains("Retrying") {
info!("Warning: {}", status);
} else if status.contains("Downloaded newer image") {
info!("Downloaded newer image: {}", image_name);
}
}
}
Err(error) => {
let error_text = error
.chain()
.map(|err| err.to_string())
.collect::<Vec<_>>()
.join(" ");
error!("Failed to pull image: {}", error_text);
return Err(error);
}
}
}
info!("Successfully pulled image: {}", image_name);
Ok(image_name.clone())
}
+6
View File
@@ -0,0 +1,6 @@
use crate::prebake::config::PrebakeConfig;
use config::Config;
pub async fn prepare_custom(_config: &PrebakeConfig, _settings: &Config) -> anyhow::Result<()> {
todo!("Custom builder is not implemented yet")
}
+11
View File
@@ -0,0 +1,11 @@
use crate::prebake::config::PrebakeConfig;
use config::Config;
use log::error;
pub async fn prepare_firecracker(
_config: &PrebakeConfig,
_settings: &Config,
) -> anyhow::Result<()> {
error!("Firecracker is not supported yet.");
Err(anyhow::anyhow!("Firecracker is not supported yet"))
}
+4
View File
@@ -0,0 +1,4 @@
pub mod baremetal;
pub mod container;
pub mod custom;
pub mod firecracker;
+36
View File
@@ -0,0 +1,36 @@
use crate::engine::{ExecutionEvent, StreamType};
pub async fn event_receiver(
mut rx: tokio::sync::mpsc::Receiver<ExecutionEvent>,
pipeline_name: String,
build_id: String,
) {
while let Some(event) = rx.recv().await {
match event {
ExecutionEvent::TaskStarted { task_id, timestamp } => {
log::debug!(
"[{}] [{}] [{}] Task started: {}",
pipeline_name, build_id, timestamp, task_id
);
}
ExecutionEvent::OutputChunk { task_id, stream, data } => {
match stream {
StreamType::Stdout => print!("[{}] [{}] [stdout] {}", pipeline_name, task_id, data),
StreamType::Stderr => eprint!("[{}] [{}] [stderr] {}", pipeline_name, task_id, data),
}
}
ExecutionEvent::TaskCompleted { task_id, result } => {
log::debug!(
"[{}] [{}] Task completed: {} (exit_code={}, duration={:?})",
pipeline_name, build_id, task_id, result.exit_code, result.duration
);
}
ExecutionEvent::TaskFailed { task_id, error } => {
log::error!(
"[{}] [{}] Task failed: {} (error={})",
pipeline_name, build_id, task_id, error
);
}
}
}
}
+244
View File
@@ -0,0 +1,244 @@
use crate::ExecutionContext;
use crate::error::PrebakeError;
use crate::prebake::config::Security;
use crate::prebake::stage::PrebakeStage;
use privdrop::PrivDrop;
use std::ffi::CString;
use std::mem::MaybeUninit;
use std::str::FromStr;
const ROOT_UID: u32 = 0;
/// Resolves a username to its corresponding UID (User ID).
///
/// This function performs a thread-safe lookup of user information using the
/// `getpwnam_r` system call, which is the reentrant version of `getpwnam`.
/// It first attempts to find the user in the system password database,
/// and if not found, falls back to parsing the input as a numeric UID.
///
/// # Security Model
///
/// This function is part of the privilege management system. It is used
/// to resolve usernames to UIDs before privilege dropping operations,
/// ensuring that processes can transition to the correct user identity.
///
/// # Parameters
///
/// * `username` - The username to look up. Can be either:
/// - A textual username (e.g., `"vulcan"`, `"nobody"`)
/// - A numeric UID string (e.g., `"1000"`) for fallback resolution
///
/// # Returns
///
/// Returns `Result<u32, PrebakeError>`:
/// * `Ok(u32)` - The resolved UID on success
/// * `Err(PrebakeError::UserNotFound)` - User does not exist in the system
/// database and is not a valid numeric UID
///
/// # Error Handling
///
/// This function handles several error cases:
/// 1. **Invalid username format**: If the username contains a null byte,
/// `CString::new` will fail, returning `UserNotFound`.
/// 2. **Buffer too small**: If `getpwnam_r` returns `ERANGE`, the buffer
/// is dynamically resized and the lookup is retried.
/// 3. **User not found**: If `getpwnam_r` returns `0` with a null pointer,
/// the function attempts to parse `username` as a numeric UID.
/// If that also fails, `UserNotFound` is returned.
///
/// # Thread Safety
///
/// This function is thread-safe because it uses `getpwnam_r` instead of
/// the non-reentrant `getpwnam`. The `getpwnam_r` function stores its
/// result in the caller-provided buffer, avoiding race conditions in
/// multi-threaded contexts.
///
/// # Implementation Details
///
/// The function uses a dynamically growing buffer strategy:
/// 1. Start with a 4KB buffer (`bufsize = 4096`)
/// 2. If `getpwnam_r` returns `ERANGE` (buffer too small), double the buffer
/// 3. Retry until the buffer is large enough or a non-ERANGE error occurs
///
/// If the user is not found in the system database, the function attempts
/// to parse the `username` string directly as a numeric UID. This allows
/// configurations to specify UIDs directly (e.g., `"0"` for root) without
/// relying on the system's user database.
///
/// # Example
///
/// ```rust,ignore
/// // Resolve a username to UID
/// match lookup_uid_by_name("vulcan") {
/// Ok(uid) => println!("User vulcan has UID {}", uid),
/// Err(e) => eprintln!("Failed to resolve user: {}", e),
/// }
///
/// // Resolve a numeric UID string (fallback)
/// match lookup_uid_by_name("1000") {
/// Ok(uid) => println!("UID 1000 resolved to {}", uid),
/// Err(e) => eprintln!("Invalid UID: {}", e),
/// }
/// ```
///
/// # See Also
///
/// * [`prebake_drop_privilege`] - Uses this function to resolve target user before dropping privileges
/// * [`PrivDrop`](privdrop::PrivDrop) - The privilege dropping utility used after UID resolution
pub fn lookup_uid_by_name(username: &str) -> Result<u32, PrebakeError> {
let c_username = CString::new(username)
.map_err(|_| PrebakeError::UserNotFound(format!("Invalid username: {}", username)))?;
let mut pwd = MaybeUninit::<libc::passwd>::uninit();
let mut pwent = std::ptr::null_mut::<libc::passwd>();
let mut bufsize: usize = 4096;
let mut pwbuf = vec![0; bufsize];
loop {
let ret = unsafe {
libc::getpwnam_r(
c_username.as_ptr(),
pwd.as_mut_ptr(),
pwbuf.as_mut_ptr() as *mut libc::c_char,
pwbuf.len(),
&mut pwent,
)
};
if ret == libc::ERANGE {
bufsize *= 2;
pwbuf.resize(bufsize, 0);
} else {
break;
}
}
// TODO: originally unsafe {pwent} here, maybe to check and remove
// let pwent = pwent ;
if pwent.is_null() {
if let Ok(uid) = username.parse::<u32>() {
return Ok(uid);
}
return Err(PrebakeError::UserNotFound(format!(
"User '{}' not found and not a valid numeric UID",
username
)));
}
let uid = unsafe { (*pwent).pw_uid };
Ok(uid)
}
/// Drops process privileges if the current execution stage exceeds the allowed privilege boundary.
///
/// # Security Model
/// This function acts as a **privilege gate**. It ensures that once the pipeline progresses beyond
/// a certain stage (`stage_expected`), the process must no longer run with elevated (Root) privileges.
///
/// # Behavior
/// - **Conditional Execution**: Only attempts to drop privileges if `stage_current > stage_expected`.
/// - **Idempotent**: Safe to call multiple times. If the process is already running as the target user,
/// it returns `Ok(())` immediately.
/// - **State Validation**:
/// - If running as Root: Attempts to switch to the target user.
/// - If running as Target User: Returns success (no-op).
/// - If running as Other Non-Root User: Returns `Err(InvalidPrivilegeState)` because privilege
/// dropping is impossible from this state (you cannot switch users without Root).
///
/// # Logic Flow
/// 1. Check if `stage_current` exceeds `stage_expected`. If not, return `Ok(())` immediately.
/// 2. Resolve the target username to a UID.
/// 3. Compare Current Effective UID vs. Target UID:
/// - **Equal**: Log info and return `Ok(())`.
/// - **Current is Root (0)**: Proceed to drop privileges using `PrivDrop`.
/// - **Current is Neither**: Log error and return `Err(InvalidPrivilegeState)`.
/// 4. Verify the new UID after dropping (sanity check) and log success.
///
/// # Arguments
/// * `stage_current`: The pipeline stage currently being executed.
/// * `stage_expected`: The last stage allowed to run with elevated privileges.
/// * `user`: The target username to drop privileges to (e.g., "nobody", "builder").
///
/// # Returns
/// - `Ok(())`: Privileges were successfully dropped, or were already correct.
/// - `Err(PrebakeError)`:
/// - `UserNotFound`: The target username does not exist.
/// - `InvalidPrivilegeState`: Process is running as an unexpected non-root user.
/// - `PrivDropFailed`: System call to change user failed.
///
/// # Caller Responsibility
/// The caller must ensure that if a privilege drop is required, this function is invoked
/// while the process still holds Root privileges. The caller is also responsible for mapping
/// the returned `Err` to an appropriate exit code (e.g., 201) if termination is desired.
pub fn prebake_drop_privilege(
stage_current: PrebakeStage,
stage_expected: PrebakeStage,
user: &str,
ctx: &mut ExecutionContext,
) -> Result<(), PrebakeError> {
if ctx.dry_run {
return Ok(());
}
if stage_current > stage_expected {
let current_uid = unsafe { libc::geteuid() };
let target_uid = lookup_uid_by_name(user).inspect_err(|_e| {
log::error!(
"Cannot drop privileges: target user '{}' does not exist at stage {}",
user,
stage_current
);
log::error!("Aborting pipeline.");
})?;
if current_uid == target_uid {
log::debug!(
"Already running as target user (UID {}), skipping privilege drop",
target_uid
);
return Ok(());
}
if current_uid != ROOT_UID {
log::error!(
"Cannot drop privileges: current UID={} is neither Root (0) nor target UID={}",
current_uid,
target_uid
);
log::error!("Aborting pipeline.");
return Err(PrebakeError::InvalidPrivilegeState {
current_uid,
target_uid,
});
}
PrivDrop::default().user(user).apply().map_err(|e| {
log::error!(
"Failed to drop privileges to {} at stage {} (after {}): {}",
user,
stage_current,
stage_expected,
e
);
log::error!("Aborting pipeline.");
PrebakeError::PrivDropFailed(e.to_string())
})?;
let new_uid = unsafe { libc::geteuid() };
log::info!(
"Successfully dropped privileges from Root to UID {} (user: {})",
new_uid,
user
);
ctx.privileged = false;
}
Ok(())
}
pub fn get_drop_after(security: &Option<Security>) -> Result<PrebakeStage, String> {
security
.clone()
.and_then(|sec| sec.drop_after)
.map(|stage_str| PrebakeStage::from_str(&stage_str))
.unwrap_or(Ok(PrebakeStage::default()))
}
+53
View File
@@ -0,0 +1,53 @@
use std::str::FromStr;
pub mod bootstrap;
pub mod environment;
pub mod hook;
pub mod depssystem;
pub mod depsuser;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum PrebakeStage {
Init,
Bootstrap,
EarlyHook,
#[default]
DepsSystem,
DepsUser,
LateHook,
Ready,
Never,
}
impl std::fmt::Display for PrebakeStage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PrebakeStage::Init => write!(f, "Init"),
PrebakeStage::Bootstrap => write!(f, "Bootstrap"),
PrebakeStage::EarlyHook => write!(f, "EarlyHook"),
PrebakeStage::DepsSystem => write!(f, "DepsSystem"),
PrebakeStage::DepsUser => write!(f, "DepsUser"),
PrebakeStage::LateHook => write!(f, "LateHook"),
PrebakeStage::Ready => write!(f, "Ready"),
PrebakeStage::Never => write!(f, "Never"),
}
}
}
impl FromStr for PrebakeStage {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"init" => Ok(PrebakeStage::Init),
"bootstrap" => Ok(PrebakeStage::Bootstrap),
"earlyhook" => Ok(PrebakeStage::EarlyHook),
"depssystem" => Ok(PrebakeStage::DepsSystem),
"depsuser" => Ok(PrebakeStage::DepsUser),
"latehook" => Ok(PrebakeStage::LateHook),
"ready" => Ok(PrebakeStage::Ready),
"never" => Ok(PrebakeStage::Never),
_ => Err(format!("Unknown stage: {}", s)),
}
}
}
@@ -0,0 +1,247 @@
//! Bootstrap stage implementation for the prebake workflow.
//!
//! This module handles the initial setup of the build environment, including:
//! - User creation for the build environment
//! - Workspace directory setup
//! - Privilege executor configuration (sudo/doas)
//!
//! The bootstrap script is generated dynamically based on the prebake configuration
//! 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::config::Bootstrap;
use crate::prebake::config::PrebakeConfig;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use which::which;
/// Executes the bootstrap stage of the prebake workflow.
///
/// This function generates a bootstrap script based on the provided configuration,
/// writes it to `/bootstrap.sh`, and then executes it within the build environment.
///
/// # Arguments
///
/// * `config` - The prebake configuration containing bootstrap, dependencies, and security settings
/// * `osinfo` - Operating system information for platform-specific configuration
/// * `ctx` - Execution context containing pipeline parameters and dry-run flag
/// * `event_tx` - Event sender for reporting stage progress (currently unused)
///
/// # Returns
///
/// Returns `Ok(())` on successful bootstrap execution, or a `BootstrapError` if:
/// - The bootstrap script generation fails
/// - The generated script does not exist at `/bootstrap.sh`
/// - Script execution fails
///
/// # Dry Run Mode
///
/// When `ctx.dry_run` is `true`, the function generates the bootstrap script
/// and logs it without executing it, allowing inspection of the generated script.
pub async fn bootstrap(
config: &PrebakeConfig,
osinfo: &os_info::Info,
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> Result<(), BootstrapError> {
let engine = Engine::new();
let bootstrap_path = PathBuf::from(BOOTSTRAP_SCRIPT_PATH);
let bootstrap_config = config.bootstrap.clone().unwrap_or_default();
if bootstrap_config.custom.is_none() {
let script = generate_bootstrap(&bootstrap_config, config, osinfo)?;
if ctx.dry_run {
log::info!("[DRY_RUN] Generated bootstrap script:\n{}", script);
return Ok(());
}
log::debug!("Generated bootstrap script:\n{}", script);
let mut file = File::create(&bootstrap_path)?;
file.write_all(script.as_bytes())?;
std::fs::set_permissions(&bootstrap_path, std::os::unix::fs::PermissionsExt::from_mode(0o755))?;
} else {
// Requires PIP(Pipeline Implicit Parameters) to be implemented
unimplemented!("Not supporting custom bootstrap.sh at this time");
}
// Check if bootstrap script exists before execution
if !bootstrap_path.exists() {
log::error!("{} does not exist", BOOTSTRAP_SCRIPT_PATH);
return Err(BootstrapError::ScriptNotFound(bootstrap_path));
}
engine
.execute_script(&bootstrap_path, ctx, event_tx)
.await
.map_err(BootstrapError::ExecutionError)?;
Ok(())
}
/// Generates a bootstrap script based on configuration and target OS.
///
/// The generated script performs the following setup tasks:
///
/// 1. **User Creation**: Creates a dedicated user for the build environment
/// using `useradd`, `adduser`, or `busybox useradd` depending on availability
///
/// 2. **Workspace Setup**: Creates the workspace directory and optionally
/// symlinks it to `/workspace` for convenient access
///
/// 3. **Privilege Executor**: Configures `sudo` or `doas` to allow the build user
/// to execute package manager commands without a password
///
/// # Arguments
///
/// * `bootstrap_config` - Bootstrap-specific configuration (user, workspace, sudoers)
/// * `config` - Full prebake configuration for dependency and package manager detection
/// * `osinfo` - Operating system information for package manager detection
///
/// # Returns
///
/// Returns the generated bootstrap script as a `String`, or a `BootstrapError` if:
/// - No suitable user creation binary is found
/// - Package manager configuration is invalid
///
/// # Script Format
///
/// The generated script uses `#!/bin/sh` and includes `set -e` for error handling.
/// It outputs "Bootstrap complete!" upon successful completion.
fn generate_bootstrap(
bootstrap_config: &Bootstrap,
config: &PrebakeConfig,
osinfo: &os_info::Info,
) -> Result<String, BootstrapError> {
let mut script = String::new();
// Generate header
script.push_str("#!/bin/sh\n");
script.push_str("# Template from HBW baker - Generated bootstrap\n");
script.push_str("set -e\n");
// Create user
let user = &bootstrap_config.user;
if user == "root" {
// Requires PIP(Pipeline Implicit Parameters) to be implemented
unimplemented!("Permission check is not implemented, failing...");
} else {
script.push_str("# User creation\n");
script.push_str(&format!("if ! id -u {} >/dev/null 2>&1; then\n", user));
if which("useradd").is_ok() {
script.push_str(&format!("useradd -m -s /bin/nologin {}\n", user));
} else if which("adduser").is_ok() {
script.push_str(&format!(
"adduser --disabled-password --gecos '' --shell /bin/nologin {}\n",
user
));
} else if which("busybox").is_ok() {
script.push_str(&format!("busybox useradd -m -s /bin/nologin {}\n", user));
} else {
log::error!("None of useradd, adduser and busybox found to add user!");
return Err(BootstrapError::UserCreationFailed(
user.to_string(),
"no available binary found".to_string(),
));
}
script.push_str("fi\n");
}
// Setup workspace
let workspace = bootstrap_config.workspace.clone().unwrap_or_default();
let workspace_path = workspace.path;
let workspace_fallback = workspace.fallback;
script.push_str("# Setup workspace\n");
script.push_str(&format!("mkdir -p {}\n", workspace_path));
script.push_str(&format!("chown {}:{} {}\n", user, user, workspace_path));
if workspace_fallback {
script.push_str(&format!("ln -sf {} /workspace\n", workspace_path));
}
// Setup privilege executor
script.push_str("# Setup privilege executor\n");
let pkg_manager = get_package_manager(config, osinfo);
if which("sudo").is_ok() {
if bootstrap_config.sudoers.is_some() {
unimplemented!("Permission check is not implemented, failing...")
}
if let Some(pkg_manager) = pkg_manager {
let allowed_commands = format!("/usr/bin/{} *", pkg_manager.binary());
let content = format!("{} ALL=(ALL:ALL) NOPASSWD {}", user, allowed_commands);
script.push_str(&format!(
"echo '{}' | tee /etc/sudoers.d/workshop > /dev/null\n",
content
));
script.push_str("chmod 440 /etc/sudoers.d/workshop\n");
}
} else if which("doas").is_ok() {
if bootstrap_config.doas.is_some() {
// Requires PIP(Pipeline Implicit Parameters) to be implemented
unimplemented!("Permission check is not implemented, failing...")
}
// We do not know how to write doas.conf lol
unimplemented!("doas is not implemented");
}
script.push_str("echo 'Bootstrap complete!'\n");
Ok(script)
}
/// Detects and selects an appropriate package manager based on configuration and OS.
///
/// This function first attempts to detect the OS's native package manager, then
/// checks if it's configured in the prebake.yml. If the detected package manager
/// is not configured, it falls back to the first configured package manager.
///
/// # Detection Priority
///
/// 1. **Detected Package Manager**: Uses `pm::detect()` to find the OS-native package manager
/// (e.g., `apt` on Debian/Ubuntu, `pacman` on Arch Linux)
/// 2. **Configured Package Manager**: If detected manager is not in config, uses the
/// first configured package manager from the dependencies section
///
/// # Arguments
///
/// * `config` - Prebake configuration containing dependencies.system configuration
/// * `osinfo` - Operating system information for package manager detection
///
/// # Returns
///
/// Returns `Some(Box<dyn PackageManager>)` if a suitable package manager is found,
/// or `None` if no package manager is configured or detectable.
fn get_package_manager(
config: &PrebakeConfig,
osinfo: &os_info::Info,
) -> Option<Box<dyn pm::PackageManager>> {
config
.dependencies
.as_ref()
.and_then(|deps| deps.system.as_ref())
.and_then(|sysdeps| {
if let Some(detected) = pm::detect(osinfo) {
if sysdeps.contains_key(detected.name()) {
log::info!("Using package manager: {} (detected)", detected.name());
return Some(detected);
} else {
log::warn!(
"Detected package manager '{}' but not configured in prebake.yml",
detected.name()
);
}
}
if let Some((name, _)) = sysdeps.iter().next()
&& let Some(pm) = pm::select(name)
{
log::warn!(
"Using package manager: {} (first configured, not detected)",
name
);
return Some(pm);
}
log::warn!("No valid package manager found in configuration");
None
})
}
@@ -0,0 +1,26 @@
#!/bin/sh
# Template from HBW baker - Minimal bootstrap
set -e
# Create user (if not exists)
if ! id -u vulcan >/dev/null 2>&1; then
if command -v useradd >/dev/null 2>&1; then
useradd -m -s /bin/nologin vulcan
elif command -v adduser >/dev/null 2>&1; then
adduser --disabled-password --gecos "" --shell /bin/nologin vulcan
fi
fi
# Configure sudoers (allow package manager commands)
mkdir -p /etc/sudoers.d/
cat > /etc/sudoers.d/vulcan << 'EOF'
vulcan ALL=(ALL:ALL) NOPASSWD: /usr/bin/pacman, /usr/bin/apt-get, /usr/bin/dnf, /usr/bin/yum, /usr/bin/apk
EOF
chmod 440 /etc/sudoers.d/vulcan
# Set up workspace directory
mkdir -p /home/vulcan/workspace
chown vulcan:vulcan /home/vulcan/workspace
ln -sf /home/vulcan/workspace /workspace # as a fallback path
echo "Bootstrap completed"
@@ -0,0 +1,101 @@
use crate::ExecutionContext;
use crate::engine::{Engine, EventSender, pm};
use crate::error::DependencyError;
use crate::prebake::config::SystemDependency;
use std::collections::HashMap;
/// Handles system dependency installation for the target platform.
///
/// This function:
/// - Detects the operating system and package manager
/// - Optionally changes the package manager mirror
/// - Optionally adds custom repositories
/// - Updates the package index
/// - Installs configured system packages
pub async fn depssystem(
config: &HashMap<String, SystemDependency>,
ctx: &ExecutionContext,
event_tx: EventSender,
) -> Result<(), DependencyError> {
let osinfo = os_info::get();
let engine = Engine::new();
// Detect package manager
let package_manager = pm::detect(&osinfo).ok_or_else(|| {
log::error!("No supported package manager found for {}", &osinfo);
DependencyError::UnsupportedPlatform(osinfo.clone())
})?;
log::info!("Detected package manager: {}", package_manager.name());
// Retrieve configuration
let Some(pm_config) = config.get(package_manager.name()) else {
log::info!(
"No configuration found for {}, skipping system dependencies",
package_manager.name()
);
return Ok(());
};
// Change mirror
if let Some(mirror) = &pm_config.mirror {
log::info!("Changing mirror to: {}", mirror);
let cmds = package_manager.change_mirror(mirror, Some(&osinfo));
for cmd in cmds {
log::debug!("Executing: {:?}", &cmd);
engine
.execute(cmd, ctx, &event_tx)
.await
.map_err(|e| DependencyError::StageFailed {
stage: "MirrorChange",
source: e,
})?;
}
}
// Add custom repository
if let Some(repos) = &pm_config.repositories {
log::info!(
"Adding {} custom repositories",
repos.as_sequence().map(|s| s.len()).unwrap_or(0)
);
let cmds = package_manager.add_repository(repos, Some(&osinfo));
for cmd in cmds {
log::debug!("Executing: {:?}", &cmd);
engine.execute(cmd, ctx, &event_tx).await.map_err(|e| {
DependencyError::StageFailed {
stage: "CustomRepoAdd",
source: e,
}
})?;
}
}
// Update package index
{
log::info!("Updating package index...");
let cmds = package_manager.update();
for cmd in cmds {
log::debug!("Executing: {:?}", &cmd);
engine.execute(cmd, ctx, &event_tx).await.map_err(|e| {
DependencyError::StageFailed {
stage: "UpdateIndex",
source: e,
}
})?;
}
}
// Install packages
if !pm_config.packages.is_empty() {
log::info!("Installing {} packages", pm_config.packages.len());
engine
.install_dependency(&pm_config.packages, &*package_manager, ctx, &event_tx)
.await
.map_err(|e| DependencyError::StageFailed {
stage: "InstallPackage",
source: e,
})?;
}
Ok(())
}

Some files were not shown because too many files have changed in this diff Show More