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!
This commit is contained in:
Catty Steve
2026-04-16 23:33:03 +08:00
parent 73e1238e20
commit a8ed935f6a
25 changed files with 2980 additions and 335 deletions
+4
View File
@@ -5,3 +5,7 @@ playground
.codeartsdoer
archived
.sisyphus
*.bak
*.clean
session*
+1211
View File
File diff suppressed because it is too large Load Diff
+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便于离线部署
-29
View File
@@ -1,29 +0,0 @@
我们正在实现一个基于Rust的CI/CD系统,名为'蜜饼工坊'(HoneyBiscuitWorkshop),取材于明日方舟世界观中小刻爱吃的蜜饼和她的管理者火神大姐的工坊。
这个CI/CD的最终用途如下:
0. 一个合格的CICD当然可以DevOps
1. 软件包自动化,例如接入AOSC的autobuild+abbs树,或者Arch Linux的ABS,实现软件包的自动构建与更新
2. 机器学习支持,支持CUDA/ROCm/Others方案运行机器学习/深度学习程序
3. 对于部分工业软件/行业软件的支持,例如允许通过Vivado实现硬件设计远程综合
这个CI/CD的特色如下:
1. 基于LLM的流水线自配置
LLM可以读取关键文件/定义的文件/预设规则/上次生成的报错来生成流水线配置
2. 流水线进度感知
具体说来程序可以通过读取的流水线日志来判断进行的进度,以估计剩余时间,或给出超时警告
3. 与koishi(Koishi.js,一个聊天机器人管理项目,https://koishi.chat/zh-CN/)等结合,实现消息推送,流水线简单管理
通过koishijs与QQ机器人/Telegram机器人等配合,实现流水线成功/失败推送和快捷的重试等功能
4. 基于cgroups/JobObject的任务资源管理与计数
资源管理可以对任务资源进行限制,资源计数可以对不同权限的账号计算配额
5. prebake.yml, bake.sh, finalize.yml的最简流水线配置,声明式+命令式结合,采用@decorator进行装饰,以增强流水线行为
细节见下
6. 单二进制发布,支持各种架构,系统与C库
当然,程序不支持没有操作系统的裸机(nostd)
7. 构建机支持裸机,容器与FireCracker虚拟机
8. 可配置的构建缓存与环境缓存
9. 本地流水线快捷验证
可以在不借助服务器的情况下快速验证流水线语法的正确性,在借助本机容器的情况下进行试构建
10. 本地文件夹推送构建
11. Vol1/2/3 用户/管理员/开发者文档
可以推送本地的任何一个文件夹到服务器,其会根据预设规则/.workshop中的文件/LLM自生成进行构建,支持增量传输
CI/CD采用Web+Server+Multi (Agent+Executor)设计。当然也有可能的CLI客户端用于上述目的。
这个CI/CD可能会结合koishi, rustyvaulthttps://rustyvault.net,使用标准的Vault也可)等外部项目构建,当然我们希望减少对外部项目的依赖,尤其是数据库(如MySQL,意即,我们尽可能在SQLite解决问题)和消息队列(如RabbitMQ),但是不阻止部署者使用这些。
finalize.yml的语法尚在设计
+366
View File
@@ -0,0 +1,366 @@
# finalize.yml.tmpl
#
# 蜜饼工坊 (HoneyBiscuitWorkshop) 构建流水线 - 完成阶段配置
#
# 版本号,用于格式兼容性检查
# 版本迁移系统内置,预期 1.0 后不再变更 schema
version: "0.0.1"
# =============================================================================
# 插件定义
# =============================================================================
# 插件在 finalize 阶段执行特定任务(如推送镜像、上传制品等)
# 插件可以是 Shell 脚本或 Rhai 脚本,由文件扩展名自动检测
#
# 插件来源格式:
# git::<git-url>@<branch>/<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 提交
# 如果该提交不在指定分支中,则执行失败
source: "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:
source: "https::example.com/trivy/trivy.sh":
user: {{ variable.trivy.user }}
password: {{ secret.trivy.password }}
fallible: true
# 短信通知插件示例(本地 Rhai 脚本)
sms-notify:
source: "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"
on_success: "[HBW Notify] Build Succeeded: {{ build.status }} ({{ build.duration }})"
on_failure: "[HBW Notify] Build Failed: {{ build.status }} ({{ build.duration }})"
on_finish_of:
stage: "bake.build"
content: "[HBW Notify] Build Progress: {{ build.status }} ({{ build.duration }})"
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/*"
# 保留时长
# 支持格式:
# - ISO 8601 周期格式(省略 P):"7D", "168H"
# - 秒数(纯数字):604800
# 所有格式等效,默认单位为秒
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:
+3
View File
@@ -6,6 +6,7 @@
# 留空:尚未定义,留作后续
# 版本号,用于格式兼容性检查
# 到目前为止,"1.0"的版本号并不意味着schema就这么确定下来
version: "1.0"
# 构建环境定义
@@ -238,3 +239,5 @@ hooks:
metadata:
description: "Rust project build environment"
maintainer: "team@example.com"
# vim: set ft=yaml ts=2 sw=2 et:
+372 -10
View File
@@ -88,6 +88,12 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -630,6 +636,16 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -782,6 +798,20 @@ dependencies = [
"const-random",
]
[[package]]
name = "duration-str"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "027cd1402a609c71a9ac333c7e3d90ee042e7a131da1a83da8c60df323f12f61"
dependencies = [
"chrono",
"rust_decimal",
"serde",
"thiserror 2.0.17",
"time",
"winnow",
]
[[package]]
name = "dyn-clone"
version = "1.0.20"
@@ -794,6 +824,22 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "email-encoding"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
dependencies = [
"base64",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -936,6 +982,21 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -1009,8 +1070,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-core",
"futures-io",
"futures-macro",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
@@ -1049,6 +1112,12 @@ dependencies = [
"wasip2",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "h2"
version = "0.4.12"
@@ -1142,6 +1211,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
"cfg-if",
"libc",
"windows-link",
]
[[package]]
name = "http"
version = "1.4.0"
@@ -1254,12 +1334,29 @@ dependencies = [
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-core",
@@ -1267,12 +1364,16 @@ dependencies = [
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1439,6 +1540,22 @@ dependencies = [
"serde_core",
]
[[package]]
name = "ipnet"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
@@ -1531,6 +1648,33 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lettre"
version = "0.11.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7"
dependencies = [
"async-trait",
"base64",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"hostname",
"httpdate",
"idna",
"mime",
"native-tls",
"nom",
"percent-encoding",
"quoted_printable",
"socket2",
"tokio",
"tokio-native-tls",
"url",
]
[[package]]
name = "libc"
version = "0.2.177"
@@ -1623,6 +1767,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe 0.2.1",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "nix"
version = "0.25.1"
@@ -1648,6 +1809,15 @@ dependencies = [
"memoffset",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num"
version = "0.4.3"
@@ -1683,9 +1853,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.1.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-integer"
@@ -1918,12 +2088,56 @@ dependencies = [
"tokio",
]
[[package]]
name = "openssl"
version = "0.10.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.112"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "ordered-multimap"
version = "0.7.3"
@@ -2087,6 +2301,12 @@ dependencies = [
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plain"
version = "0.2.3"
@@ -2243,6 +2463,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "quoted_printable"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
[[package]]
name = "r-efi"
version = "5.3.0"
@@ -2365,6 +2591,46 @@ version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -2403,6 +2669,16 @@ dependencies = [
"ordered-multimap",
]
[[package]]
name = "rust_decimal"
version = "1.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a"
dependencies = [
"arrayvec",
"num-traits",
]
[[package]]
name = "rustix"
version = "1.1.3"
@@ -2437,7 +2713,7 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3"
dependencies = [
"openssl-probe",
"openssl-probe 0.1.6",
"rustls-pki-types",
"schannel",
"security-framework",
@@ -2530,7 +2806,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -2765,6 +3041,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
@@ -2777,6 +3056,27 @@ dependencies = [
"syn",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "tar"
version = "0.4.44"
@@ -2843,9 +3143,9 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.45"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
@@ -2858,15 +3158,15 @@ dependencies = [
[[package]]
name = "time-core"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.25"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
@@ -2929,6 +3229,16 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -3071,6 +3381,24 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags 2.10.0",
"bytes",
"futures-util",
"http",
"http-body",
"iri-string",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@@ -3243,6 +3571,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -3296,6 +3630,19 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c"
dependencies = [
"cfg-if",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.106"
@@ -3428,6 +3775,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -3629,15 +3987,19 @@ dependencies = [
"clap",
"config",
"criterion",
"duration-str",
"env_logger",
"futures-util",
"glob",
"lazy_static",
"lettre",
"libc",
"log",
"minijinja",
"os_info",
"privdrop",
"regex",
"reqwest",
"semver",
"serde",
"serde_json",
+4
View File
@@ -26,9 +26,12 @@ 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"
@@ -47,6 +50,7 @@ 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"
[dev-dependencies]
+22 -3
View File
@@ -32,7 +32,7 @@ pub async fn bake(
let workspace = get_workspace(&prebake);
let functions = parser::parse_script(&script_content)?;
let has_pipeline = functions.iter().any(|f| {
let has_pipeline = functions.pipeline_functions.iter().any(|f| {
f.decorators
.iter()
.any(|d| matches!(d, decorator::Decorator::Pipeline))
@@ -76,10 +76,13 @@ pub async fn bake(
)?;
engine.execute_script(&script, &ctx, &event_tx).await?;
} else {
let sorted_functions = schedule::sort_function(functions)?;
let sorted_functions = schedule::sort_function(functions.pipeline_functions)?;
let remaining = functions.remaining_code;
for func in sorted_functions {
let mut modified_func = func.clone();
modified_func.body = remaining.clone() + &func.body;
let script = builder::build_script(
&func,
&modified_func,
bake_base_path,
&workspace,
None,
@@ -119,3 +122,19 @@ fn privileged(prebake_config: &PrebakeConfig) -> bool {
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 {
// }
+262 -293
View File
@@ -1,4 +1,4 @@
use super::decorator::{Decorator, parse_decorator};
use super::decorator::{parse_decorator, Decorator};
use crate::bake::BakeError;
use lazy_static::lazy_static;
use regex::Regex;
@@ -28,9 +28,7 @@ impl Function {
#[derive(Debug)]
enum ParserState {
Normal,
Decorator {
decorators: Vec<Decorator>,
},
AfterDecorator,
InFunction {
name: String,
decorators: Vec<Decorator>,
@@ -39,22 +37,27 @@ enum ParserState {
},
}
pub fn parse_script(text: &String) -> Result<Vec<Function>, BakeError> {
let mut functions = Vec::new();
#[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; // For error reporting, make it 1-based
dbg!(&line_number, &line);
dbg!(&state);
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)? {
state = ParserState::Decorator {
decorators: vec![decorator],
};
pending_decorators.push(decorator);
}
state = ParserState::AfterDecorator;
continue;
}
if FUNCTION_REGEX.find(line).is_some()
@@ -69,40 +72,55 @@ pub fn parse_script(text: &String) -> Result<Vec<Function>, BakeError> {
false,
)
};
let decorators_captured = if let ParserState::Decorator { decorators } = &state
{
decorators.clone()
} else {
Vec::new()
};
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 {
if brace_depth != 0 {
state = ParserState::InFunction {
name,
decorators: decorators_captured,
decorators: pending_decorators.clone(),
body: line.to_string() + "\n",
brace_depth,
};
}
else{
functions.push(Function {
name: name.clone(),
decorators: decorators_captured,
body: line.to_string() + "\n",
});
} 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::Decorator { decorators } => {
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)? {
decorators.push(decorator);
pending_decorators.push(decorator);
}
continue;
}
else if FUNCTION_REGEX.find(line).is_some()
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>>();
@@ -114,28 +132,49 @@ pub fn parse_script(text: &String) -> Result<Vec<Function>, BakeError> {
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 {
if brace_depth != 0 {
state = ParserState::InFunction {
name,
decorators: decorators.clone(),
decorators: pending_decorators.clone(),
body: line.to_string() + "\n",
brace_depth,
};
}
else{
functions.push(Function {
name: name.clone(),
decorators: decorators.clone(),
body: line.to_string() + "\n",
});
} 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;
}
else {
state = ParserState::Normal;
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,
@@ -143,25 +182,32 @@ pub fn parse_script(text: &String) -> Result<Vec<Function>, BakeError> {
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;
dbg!(&brace_depth);
*brace_depth -= tokens.iter().filter(|&token| token == "}").count() as i16;
dbg!(&brace_depth);
if *brace_depth == 0 && !body.trim().is_empty() {
functions.push(Function {
name: name.clone(),
decorators: decorators.clone(),
body: body.clone(),
});
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;
}
}
}
dbg!(&state);
}
if let ParserState::InFunction {
name, brace_depth, ..
} = state
@@ -174,8 +220,8 @@ pub fn parse_script(text: &String) -> Result<Vec<Function>, BakeError> {
),
});
}
if let ParserState::Decorator { decorators } = state {
let last_decorator = decorators.last().unwrap(); // Safe to unwrap because we only enter this state if we have at least one decorator
if !pending_decorators.is_empty() {
let last_decorator = pending_decorators.last().unwrap();
return Err(BakeError::ScriptGenerationFailed {
script: "bake.sh".to_string(),
reason: format!(
@@ -184,9 +230,12 @@ pub fn parse_script(text: &String) -> Result<Vec<Function>, BakeError> {
),
});
}
Ok(functions)
}
Ok(ParsedScript {
pipeline_functions,
remaining_code,
})
}
#[cfg(test)]
mod tests {
@@ -195,304 +244,224 @@ mod tests {
#[test]
fn test_empty_script() {
let script = "";
let functions = parse_script(&script.to_string()).unwrap();
assert!(functions.is_empty());
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 functions = parse_script(&script.to_string()).unwrap();
assert!(functions.is_empty());
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() {
fn test_function_with_parens_non_pipeline() {
let script = "func() { echo hi; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].name, "func");
let result = parse_script(&script.to_string()).unwrap();
assert!(result.pipeline_functions.is_empty());
assert!(result.remaining_code.contains("func()"));
}
#[test]
fn test_function_keyword_with_parens() {
let script = "function func() { echo hi; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].name, "func");
}
#[test]
fn test_function_keyword_without_parens() {
let script = "function func { echo hi; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].name, "func");
}
#[test]
fn test_single_decorator() {
fn test_pipeline_function() {
let script = "# @pipeline\nfunc() { echo hi; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].decorators.len(), 1);
assert_eq!(functions[0].decorators[0], Decorator::Pipeline);
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_decorators() {
let script = "# @pipeline\n# @fallible\nfunc() { echo hi; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].decorators.len(), 2);
}
#[test]
fn test_decorator_invalidated_by_empty_line() {
let script = "# @pipeline\n\nfunc() { echo hi; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].decorators.is_empty());
}
#[test]
fn test_decorator_invalidated_by_code() {
let script = "# @pipeline\necho before\nfunc() { echo hi; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].decorators.is_empty());
}
#[test]
fn test_decorator_after_function() {
let script = "# @pipeline\nfunc1() { echo 1; }\nfunc2() { echo 2; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 2);
assert_eq!(functions[0].decorators.len(), 1);
assert!(functions[1].decorators.is_empty());
}
#[test]
fn test_nested_braces() {
let script = "func() { if true; then { echo hi; } fi }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
}
#[test]
fn test_multiline_function() {
let script = "func() {\necho line1\necho line2\n}";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].body.contains("echo line1"));
assert!(functions[0].body.contains("echo line2"));
}
#[test]
fn test_multiple_functions() {
let script = "func1() { echo 1; }\nfunc2() { echo 2; }\nfunc3() { echo 3; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 3);
}
#[test]
fn test_all_decorator_types() {
let script = "# @pipeline\n# @fallible\n# @loop(3)\n# @timeout(60)\n# @export\n# @parallel\nfunc() { echo hi; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].decorators.len(), 6);
}
#[test]
fn test_decorator_if() {
let script = "# @if(condition)\nfunc() { echo hi; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(
functions[0].decorators[0],
Decorator::If("condition".to_string())
);
}
#[test]
fn test_decorator_retry() {
let script = "# @retry(3, 5)\nfunc() { echo hi; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].decorators[0], Decorator::Retry(3, 5));
}
#[test]
fn test_decorator_pipe() {
let script = "# @pipe(step1, step2)\nfunc() { echo hi; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(
functions[0].decorators[0],
Decorator::Pipe(vec!["step1".to_string(), "step2".to_string()])
);
}
#[test]
fn test_decorator_after_dep() {
let script = "# @after(other)\nfunc() { echo hi; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(
functions[0].decorators[0],
Decorator::After("other".to_string())
);
}
#[test]
fn test_decorator_health() {
let script = "# @health(/health)\nfunc() { echo hi; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(
functions[0].decorators[0],
Decorator::Health("/health".to_string())
);
}
#[test]
fn test_template_style_parallel_retry() {
let script = r#"
fn test_mixed_functions() {
let script = r#"#!/bin/bash
# comment
function helper() { echo "helper"; }
# @pipeline
# @if(WS_PIPELINE_ARCH == amd64)
# @timeout(720)
# @retry(3, 5)
# @parallel
fetch_library() {
get_custom_library ffmpeg amd64
}
prepare() { echo "prepare"; }
echo "Done."
"#;
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].decorators.len(), 5);
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_function_name_with_underscores_and_numbers() {
let script = "func_123_test() { echo hi; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].name, "func_123_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_function_empty_body() {
let script = "empty_func() { }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].name, "empty_func");
}
fn test_user_example() {
let script = r#"#!/bin/bash
#[test]
fn test_function_with_semicolons() {
let script = "multi() { echo a; echo b; echo c; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].body.contains("echo a;"));
}
#Implicit set -euo pipefail
#[test]
fn test_comment_between_decorator_and_function() {
let script = "# @pipeline\n# this is a comment\nfunc() { echo hi; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].decorators.is_empty());
}
# function to be used by any pipeline stage
function get_custom_library() {
curl -LO https://archive.ppy.sh/$2/$1.tar.gz
}
#[test]
fn test_whitespace_only_script() {
let script = " \n\n\t\n ";
let functions = parse_script(&script.to_string()).unwrap();
assert!(functions.is_empty());
}
# @pipeline
prepare_dotnet() {
curl -L https://dot.net/v1/dotnet-install.sh | bash
bake_info "Successfully installed dotnet"
}
#[test]
fn test_function_without_keyword_no_parens() {
let script = "simple { echo hi; }";
# @pipeline
fetch_source() {
git clone https://github.com/ppy/osu.git --depth 1
}
echo "Done."
"#;
let result = parse_script(&script.to_string());
assert!(result.is_err() || result.unwrap().is_empty());
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_malformed_no_closing_brace() {
let script = "incomplete() { echo hi;";
let functions = parse_script(&script.to_string());
assert!(functions.is_err());
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_decorator_with_whitespace_variations() {
let script = "#\t@pipeline \nfunc() { echo hi; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].decorators.len(), 1);
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_multiple_functions_with_mixed_decorators() {
let script = "# @pipeline\nfunc1() { echo 1; }\n# @parallel\nfunc2() { echo 2; }\nfunc3() { echo 3; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 3);
assert_eq!(functions[0].decorators.len(), 1);
assert_eq!(functions[1].decorators.len(), 1);
assert!(functions[2].decorators.is_empty());
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_function_with_command_substitution() {
let script = "cmd_sub() { local x=$(date +%s); echo $x; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].body.contains("$(date"));
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_function_with_case_statement() {
let script = "case_func() { case $x in a) echo a;; b) echo b;; esac; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].body.contains("case $x"));
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_very_short_function_names() {
let script = "a() { echo; }\nf() { echo; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 2);
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_function_with_redirect() {
let script = "redirect_func() { echo hi > /tmp/out.txt; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].body.contains("> /tmp/out.txt"));
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_function_with_pipe_in_body() {
let script = "pipe_func() { cat file.txt | grep pattern | wc -l; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].body.contains("| grep"));
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_decorator_at_end_of_script() {
let script = "func() { echo hi; }\n# @pipeline";
let functions = parse_script(&script.to_string());
assert!(functions.is_err());
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_functions_with_only_comments_between() {
let script = "func1() { echo 1; }\n# comment\n# another\nfunc2() { echo 2; }";
let functions = parse_script(&script.to_string()).unwrap();
assert_eq!(functions.len(), 2);
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"));
}
}
+1
View File
@@ -3,3 +3,4 @@ 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 BAKER_INTERNAL_CONFIG_PREFIX: &str = "_bakerinternal_";
+49
View File
@@ -0,0 +1,49 @@
pub mod config;
pub mod error;
pub mod stage;
pub mod plugin;
pub mod template;
pub mod event;
use crate::cli::Cli;
use std::path::Path;
pub use config::*;
pub use error::FinalizeError;
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,
) -> Result<(), FinalizeError> {
// Initialize
let 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));
};
let mut ctx = crate::ExecutionContext {
pipeline_name: cli.pipeline.clone(),
task_id: format!("{}-{}-finalize", cli.pipeline, cli.build_id),
username: cli.username.clone(),
dry_run: cli.dry_run,
privileged: false, // really?
..Default::default()
};
let (tx, event_rx) = tokio::sync::mpsc::channel(256);
let event_tx: crate::engine::EventSender = Some(tx);
let pipeline_name = cli.pipeline.clone();
let build_id = cli.build_id.clone();
let receiver_handle = tokio::spawn(event::event_receiver(event_rx, pipeline_name, build_id));
// Register Plugins
todo!();
}
+269
View File
@@ -0,0 +1,269 @@
//! Finalize stage configuration types
//!
//! This module defines the configuration schema for the finalize stage,
//! which handles artifact collection, publishing, and notifications.
use duration_str::deserialize_duration;
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use serde_yaml::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
/// 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 configurations organized by priority groups
/// Lower number = higher priority
/// Within same priority, notifications run in parallel
/// On failure, next priority group is triggered
#[serde(default)]
pub notification: HashMap<u32, HashMap<String, NotificationMethod>>,
/// Artifact definitions
#[serde(default)]
pub artifact: Vec<ArtifactDef>,
/// Cleanup configuration
#[serde(default)]
pub cleanup: CleanupConfig,
}
/// 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,
/// Whether the plugin should be resolved after bake stage
#[serde(default)]
pub late_binding: bool,
/// 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,
}
/// 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
/// Formats: "7D", "168H", "604800", "1w", "7 days"
#[serde(
default = "default_retention",
deserialize_with = "deserialize_duration"
)]
pub retention: Duration,
/// 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: default_retention(),
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,
}
// Default value functions
fn default_retention() -> Duration {
Duration::from_secs(7 * 24 * 3600)
}
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)),
}
// TODO: Schema-based validation for plugin configs
// TODO: Validate that publish targets reference existing plugins
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
+64
View File
@@ -0,0 +1,64 @@
use std::path::PathBuf;
use thiserror::Error;
use crate::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),
}
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,
}
}
}
#[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,
}
}
+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
);
}
}
}
}
+113
View File
@@ -0,0 +1,113 @@
use crate::finalize::RawPluginMap;
use crate::{EventSender, ExecutionContext, finalize::error::PluginError};
use async_trait::async_trait;
use std::collections::HashMap;
mod internal;
pub struct FinalizePlugin {
metadata: serde_yaml::Value,
argument: serde_yaml::Value,
pub entry: Box<dyn AsyncPluginFn>,
}
impl FinalizePlugin {
pub fn new(entry: Box<dyn AsyncPluginFn>) -> Self {
Self {
metadata: serde_yaml::Value::Null,
argument: serde_yaml::Value::Null,
entry,
}
}
pub fn with_metadata(mut self, metadata: serde_yaml::Value) -> 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, ctx: &ExecutionContext, event_tx: &EventSender) -> PluginResult {
self.entry
.call(self.metadata.clone(), self.argument.clone(), ctx, event_tx)
.await
}
}
type PluginMap = HashMap<String, FinalizePlugin>;
type PluginResult = Result<(), PluginError>;
#[async_trait]
pub trait AsyncPluginFn: Send + Sync {
async fn call(
&self,
metadata: serde_yaml::Value,
argument: serde_yaml::Value,
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> PluginResult;
}
pub fn register(external_plugins: RawPluginMap) -> Result<PluginMap, PluginError> {
let mut plugins: PluginMap = HashMap::new();
// Register internal plugin
for i in internal::get_internal_plugin() {
plugins.insert(i.name.to_string(), FinalizePlugin::new(i.entry));
}
// Register external plugin
for (name, data) in external_plugins.into_iter() {
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;
}
plugins.insert(
name,
// TODO: Implement function for Shell & Rhai
// Use dummy function to please compiler
FinalizePlugin::new(Box::new(internal::dummy::Dummy))
.with_metadata(config.runtime.manifest.unwrap_or_default())
.with_argument(config.config),
);
}
Ok(plugins)
}
/// Calls a registered plugin by name, passing the execution context and event sender.
///
/// # Arguments
///
/// * `plugins` - Map of registered plugin names to their [`FinalizePlugin`] instances
/// * `name` - Name of the plugin to invoke
/// * `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: String,
config: serde_yaml::Value,
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> PluginResult {
let plugin = plugins
.get(&name)
.ok_or_else(|| PluginError::UnknownPlugin(name))?;
// TODO: Shallow argument merge
plugin.with_argument(config).call(ctx, event_tx).await
}
@@ -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,18 @@
use crate::finalize::plugin::PluginError;
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: serde_yaml::Value,
_argument: serde_yaml::Value,
_ctx: &ExecutionContext,
_event_tx: &EventSender,
) -> Result<(), PluginError> {
Ok(())
}
}
@@ -0,0 +1,17 @@
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: serde_yaml::Value,
_argument: serde_yaml::Value,
_ctx: &ExecutionContext,
_event_tx: &EventSender,
) -> Result<(), PluginError> {
unimplemented!("Requires workshop-base to be implemented!");
}
}
@@ -0,0 +1,18 @@
use crate::finalize::plugin::PluginError;
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
use async_trait::async_trait;
pub struct Mail;
#[async_trait]
impl AsyncPluginFn for Mail {
async fn call(
&self,
_metadata: serde_yaml::Value,
_argument: serde_yaml::Value,
_ctx: &ExecutionContext,
_event_tx: &EventSender,
) -> Result<(), PluginError> {
Ok(())
}
}
@@ -0,0 +1,18 @@
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: serde_yaml::Value,
_argument: serde_yaml::Value,
_ctx: &ExecutionContext,
_event_tx: &EventSender,
) -> Result<(), PluginError> {
Ok(())
}
}
@@ -0,0 +1,18 @@
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: serde_yaml::Value,
_argument: serde_yaml::Value,
_ctx: &ExecutionContext,
_event_tx: &EventSender,
) -> Result<(), PluginError> {
Ok(())
}
}
+10
View File
@@ -0,0 +1,10 @@
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum FinalizeStage {
Register,
EarlyHook,
Artifact,
Publish,
Notify,
LateHook,
Clean,
}
+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
+1
View File
@@ -4,6 +4,7 @@
pub mod daemon;
pub mod engine;
pub mod error;
pub mod finalize;
pub mod monitor;
pub mod prebake;
pub mod bake;