feat(pm): add apk/dnf implementations and multi-distro test
CI / ${{ matrix.crate }} (workshop-engine) (push) Failing after 28m8s
CI / ${{ matrix.crate }} (workshop-baker) (push) Failing after 33m39s

infrastructure
This commit is contained in:
Catty Steve
2026-04-27 23:01:43 +08:00
parent 20e2ab4224
commit cf317b55c6
28 changed files with 1848 additions and 506 deletions
+1
View File
@@ -29,6 +29,7 @@
- [测试策略](zh-CN/vol3_dev/testing_strategy.md)
- [插件系统指南](zh-CN/vol3_dev/plugin_system_guide.md)
- [Engine 模块指南](zh-CN/vol3_dev/engine_module_guide.md)
- [设计决策](zh-CN/vol3_dev/design_decisions.md)
- [添加包管理器](zh-CN/vol3_dev/add_package_manager.md)
- [自定义插件](zh-CN/vol3_dev/custom_plugin.md)
- [自定义 Decorator](zh-CN/vol3_dev/custom_decorator.md)
+412
View File
@@ -0,0 +1,412 @@
# 设计决策
本文档记录了 HoneyBiscuitWorkshop 架构演进过程中的关键设计决策及其背后的思考。
> **注意**:本文档面向开发者,记录的是**目标架构**的设计方向,而非当前代码的实现现状。
> 当前 bakerd 尚未完全实现(`daemon.rs` 为 `todo!()`),bare mode 仅用于开发调试。
---
## 1. Bare Mode 与 Daemon Mode
### 决策
**Bare mode 仅用于开发调试,不承载生产语义。**
### 背景
当前代码提供两种运行入口:
| 模式 | 入口 | 状态 | 用途 |
|------|------|------|------|
| **Bare mode** | `cargo run -- prebake/bake/finalize <args>` | 可用 | 开发调试 |
| **Daemon mode** | `cargo run --` (无子命令) | `todo!()` | 生产 |
### 推论
- Bare mode 中 `ctx.privileged` 是写死的 fake 值(`Prebake: true`, `Bake: false`),不代表生产行为
- Bare mode 中 `os_info::get()` 读取的是 HOST 的 OS 信息,不代表环境内的 OS
- 不要从 bare mode 的行为推导生产路径的问题
- 不要为了"修复" bare mode 的问题而给生产设计增加不必要的抽象
### 代码残留
当前 `bake.rs` 中存在以下已在设计层面淘汰、但因 bare mode 未被清理的代码:
- `fn privileged()` — 从未被调用
- `type TaskResult` — 从未被使用
- `type TaskFn` — 从未被使用
这些是 `bake.rs` 废弃功能的残留,不涉及生产设计。清理它们不改变任何行为。
---
## 2. 环境模型
### 决策
**bakerd 先创建环境(容器/VM/裸机),然后 baker 在环境内执行全部三个阶段(prebake → bake → finalize)。**
### 架构
```
bakerd
├── prepare_environment (容器/VM/裸机) ← 先建隔离环境
├── prebake (全在环境内执行) ← 包括 depssystem 的包安装
├── bake (全在环境内执行) ← 构建脚本
├── finalize (全在环境内执行) ← 部署通知
└── cleanup
```
### 推论
- `prepare_environment` 不是 prebake 的一个 stage——它是 prebake 的前置条件
- `depssystem.rs``os_info::get()` 读取的应当是**环境内**的 `/etc/os-release`,逻辑正确
- 容器镜像只需拉取(`prepare_container`),不需要在代码层面管理容器生命周期——那是 bakerd 的职责
- 当前 `prepare_container` 返回的 `_container` 被丢弃,是因为 bakerd 尚未实现,不是设计缺陷
### 环境类型
```yaml
env:
type: container # Docker/Podman 容器
# type: firecracker # Firecracker 微 VM
# type: baremetal # 裸机直接执行
image: ubuntu:24.04
```
环境声明决定了:
1. 隔离级别(进程级 / VM级 / 无隔离)
2. 操作系统和工具链(通过 `image`
3. 包管理器(从 OS 推导)
---
## 3. PM 选择链
### 决策
**包管理器(PM)应从环境声明中推导,而不是通过运行时探测。**
### 选择链
```
用户声明 bakerd 确定 执行时确定
env.image: ubuntu:24.04
容器运行时 (Docker/VM)
│ os_info::get() 在环境内
OS: Ubuntu 24.04
│ 从已知映射推导
PM: apt
│ depssystem 使用 PM trait 操作
apt install -y {packages}
```
### 为什么淘汰 detect()
`pm::detect()` 的当前逻辑:
```rust
pub fn detect(distro: &os_info::Info) -> Option<Box<dyn PackageManager>> {
all_managers().into_iter().find(|pm| pm.adopt(distro))
}
```
它在解决一个**已经知道答案的问题**:
- 用户声明了 `image: ubuntu:24.04`
- 容器内 `/etc/os-release` 确认了 Ubuntu
- `detect()` 遍历所有 PM 并匹配,得出 apt
- **结果和环境声明一致,但绕了一大圈**
`detect()` 的唯一价值场景:
- **Bare mode** 用户没配环境时,省一个配置项
- 这在生产路径中不应出现(bakerd 总是提供环境)
### 推论
- PM trait 保留(见下节),但 `adopt()` 方法在生产路径中不需要
- `detect()` / `adopt()` / `os_info::get()` 这条路径应当是 dev-only 的便利设施
- Daemon 模式中 PM 的选择依据是环境声明,不是运行时探测
- 当前 `os_info::Type` 的 58 个变体覆盖不全(仅 Pacman 可用)——这是 bare mode 的问题,不影响生产设计
---
## 4. PM Trait 的职责边界
### 决策
**PM trait 保留核心操作能力,淘汰探测分类能力。**
### 核心方法
```rust
pub trait PackageManager {
fn name(&self) -> &'static str;
fn update(&self) -> Vec<Command>;
fn install(&self, package: &[String]) -> Vec<Command>;
fn change_mirror(&self, repo: &str, osinfo: Option<&Info>) -> Vec<Command>;
fn add_repository(&self, repo: &Value, osinfo: Option<&Info>) -> Vec<Command>;
}
```
这四个方法是 PM-specific 的操作,具有真实复杂度:
| 操作 | PM 间差异 | 原因 |
|------|-----------|------|
| `install` | 参数完全不同 | `apt install -y` vs `pacman -S --noconfirm` |
| `update` | 命令不同 | `apt update` vs `pacman -Sy` |
| `change_mirror` | 配置格式不同 | sources.list vs mirrorlist vs .repo |
| `add_repository` | 仓库机制不同 | PPA vs AUR vs COPR |
### 为什么镜像源和仓库在容器中仍然需要
容器不解决源管理问题:
```
docker pull ubuntu:24.04
# 容器内 sources.list → archive.ubuntu.com
# 但用户在北京 → 需要 mirror.tuna.tsinghua.edu.cn
# 这不是预置在镜像中的——源是运行态配置
```
`change_mirror``add_repository` 都是**容器运行时需要**的操作,不能预置在基础镜像中。PM trait 的存在价值就在于这些操作。
### 淘汰的方法
```rust
fn adopt(&self, distro: &Info) -> bool; // 生产路径不需要
fn binary(&self) -> &'static str; // 从未被使用
```
### 实现策略
在 daemon 模式下,PM 的选型由环境声明确定(`image: ubuntu:24.04``apt`),PM 实现仅需按需注册,不需要自述适配范围:
```rust
// 不再需要 detect() + adopt()
// PM 实例由工厂方法根据 PM 名称字符串创建
pub fn from_name(name: &str) -> Option<Box<dyn PackageManager>> {
match name {
"apt" => Some(Box::new(Apt)),
"pacman" => Some(Box::new(Pacman)),
"dnf" => Some(Box::new(Dnf)),
_ => None,
}
}
```
---
## 5. 发行版多态
### 决策
**流水线不应是发行版多态的。发行版差异由容器化解决。**
### 原因
流水线的执行体(bake 脚本)天然发行版绑定:
```bash
# @pipeline
function build() {
cmake -B build -DCMAKE_INSTALL_PREFIX=/usr # Linux 路径
make -j$(nproc)
}
```
- 换到 macOS`/usr` 只读,路径不同
- 换到 WindowsMSVC 而不是 GCC
- 换到 Alpinemusl 而不是 glibc
这与 PM 的差异是同一类问题,但 PM 抽象不能解决它。**容器才是解决方案:**
```yaml
# 在 Ubuntu 上构建
env:
type: container
image: ubuntu:24.04
```
```yaml
# 在 Alpine 上构建
env:
type: container
image: alpine:3.19
```
### 各层面的发行版相关性
| 层面 | 发行版相关 | 解决方案 |
|------|-----------|----------|
| DepsSystem(系统包) | ✅ | 容器内 PM |
| DepsUser(用户包管理器) | ❌ | 天然无关(cargo/npm/pip 跨发行版一致) |
| Bake(构建脚本) | ✅ | 容器内执行 |
| Finalize(后处理) | ❌ | 纯产物操作,无关发行版 |
### UPM 的特殊位置
用户包管理器(UPM,如 cargo、npm、pip、nix)天然跨发行版:
```bash
cargo install --locked bat # Ubuntu 和 Arch 上都一样
npm install -g typescript # Ubuntu 和 Arch 上都一样
```
`depsuser.rs` 处理的正是这个层面。但 `collect_upm_sysdeps()` 需要知道系统 PM——这是 UPM 和系统 PM 之间的 bridge。在设计上,这个 bridge 的 PM 信息应来自环境声明,而非 `detect()`
---
## 6. drop_after 安全模型
### 决策
**`drop_after` 指定流水线在哪个 stage 之后从 root 降权到 vulcan 用户。合理的默认值是 `DepsUser`。**
### Stage 顺序
```rust
pub enum PrebakeStage {
Init,
Bootstrap,
EarlyHook,
DepsSystem, // ← 默认
DepsUser,
LateHook,
Ready,
Never, // ← 永远不降权
}
```
### 各 stage 的特权需求
| Stage | 需要 root | 原因 |
|-------|-----------|------|
| Bootstrap | ✅ | 创建系统用户(vulcan)、配置 sudo/doas |
| EarlyHook | ❓ | 用户自定义 |
| DepsSystem | ✅ | `apt install` 需要 root |
| DepsUser | ❌ | `cargo install` 不需要 root |
| LateHook | ❓ | 用户自定义 |
| Ready / Bake | ❌ | 构建应以非特权用户运行 |
### 正确的配置
```yaml
security:
drop_after: "DepsUser"
```
执行效果:
```
Bootstrap root ← 创建用户
EarlyHook root ← hook 以 root 运行
DepsSystem root ← apt install -y {packages}
DepsUser root ← cargo install {packages}
LateHook vulcan ← 降权,用户脚本不能 rm -rf /
Ready vulcan
Bake vulcan ← 构建以 vulcan 运行
```
### 禁止的配置
`drop_after` 设置为 `Bootstrap``EarlyHook` 在当前 stage 顺序下会损坏 DepsStage(需要 root 权限的 `apt install` 在降权后无法执行)。
**配置验证应拒绝 `drop_after < DepsUser`**(以 root 运行的 user 级包安装没有意义,但 DepsSystem 必须 root)。
### 特殊值 Never
```yaml
security:
drop_after: "Never" # 整条流水线以 root 运行
```
适用场景:Docker 构建、内核模块编译等需要 root 权限的操作。Bootstrap 的 `user: root` 配置也会产生相同效果。
---
## 7. 当前代码中的真问题
以下问题是当前代码中确实存在的,与 bare/daemon 模式无关:
### 7.1 Apt::adopt 永远返回 false
```rust
// workshop-engine/src/pm/apt.rs:20-23
fn adopt(&self, distro: &Info) -> bool {
return false; // ← 永远 false
// todo!(); // ← 死代码
}
```
- `adopt()``return false` 后无法执行 `todo!()`
- Apt 永远不会被 `detect()` 选中
- 后果:Debian/Ubuntu 用户在 bare mode 下永远检测不到 PM
- 修复方向:移除 return false,实现真正的 adopt 逻辑(Debian/Ubuntu/Mint/Pop 等发行版的匹配)
### 7.2 PM 覆盖严重不全
当前 `all_managers()` 仅注册了 Pacman
```rust
pub fn all_managers() -> Vec<Box<dyn PackageManager>> {
vec![
// Box::new(apt::Apt), // adopt 坏了
Box::new(pacman::Pacman), // 唯一可用
// Box::new(dnf::Dnf), // 未实现
]
}
```
os_info 3.14.0 定义了 58 种发行版类型,Pacman 仅覆盖其中 9 种(Arch 系)。其余 49 种无 PM 覆盖。
### 7.3 死代码
| 位置 | 内容 | 状态 |
|------|------|------|
| `bake.rs:98` | `fn privileged()` | 定义但未调用 |
| `bake.rs:108` | `type TaskResult` | 定义但未使用 |
| `bake.rs:110` | `type TaskFn` | 定义但未使用 |
| `workshop-baker/src/prebake/stage/environment.rs` | `prepare_environment()` | 定义但未接入执行流 |
---
## 8. Daemon 设计原则(汇总)
以上讨论最终可以提炼为以下设计原则,供 bakerd 重构时参考:
### P1. 环境声明驱动
```
用户声明 env.image → bakerd 创建环境 → 环境内确定 PM → depssystem 操作 PM
```
PM 的选择权在环境声明,不在运行时探测。`detect()` 不出现在生产路径中。
### P2. 容器是发行版隔离的边界
发行版差异在容器边界被隔断。容器内是已知、确定的环境。PM trait 只负责"已知 PM 的操作"install、update、mirror、repo),不负责"猜是什么 PM"。
### P3. 降权边界可配置但需校验
`drop_after` 从 root 降权到 vulcan,默认在 DepsUser 之后。配置验证需确保:降权不会损坏需要 root 的 stage。
### P4. 最小化 trait 抽象
PM trait 保留操作系统所需的核心方法。不在 trait 中嵌入分类功能(adopt)。分类是一个数据映射问题,不是多态问题。
### P5. Bare mode 是开发工具,不承载生产行为
Bare mode 的行为(host 上直接执行、write static privileged 等)不代表生产设计。不要为了优化 bare mode 而增加生产路径的抽象负担。
### P6. 天然跨发行版的部分不需要抽象
UPMcargo/npm/pip/nix)跨发行版一致,不需要 PM 级别的抽象,不需要 detect,不需要 distro 感知。trait 只应用于确实有 PM-specific 差异的部分。
+64 -75
View File
@@ -1,112 +1,101 @@
#!/bin/bash
set -euo pipefail
# =============================================================================
# HoneyBiscuitWorkshop System Test Runner
# =============================================================================
# Usage: ./runner.sh [auto|manual] [-- pytest-args...]
#
# Modes:
# auto - Build, run all tests, collect reports, cleanup (default)
# manual - Build, start container shell for interactive testing
#
# Examples:
# ./runner.sh # Run all tests automatically
# ./runner.sh auto -k test_prebake # Run only prebake tests
# ./runner.sh manual # Drop into container shell
# =============================================================================
# Usage:
# ./runner.sh # Arch baker tests (hbw-system-test:latest)
# ./runner.sh --image hbw-test-pm:test-apt # Debian engine PM tests
# ./runner.sh -k test_install # Arch baker, filtered
MODE="${1:-auto}"
shift 2>/dev/null || true
PYTEST_ARGS="$@"
MODE="auto"
TEST_IMAGE=""
TEST_DIR="tests/baker"
PYTEST_ARGS=""
while [[ $# -gt 0 ]]; do
case "$1" in
--image) TEST_IMAGE="$2"; TEST_DIR="tests/engine"; shift 2 ;;
manual) MODE="manual"; shift ;;
auto) MODE="auto"; shift ;;
--) shift; PYTEST_ARGS="$@"; break ;;
*) PYTEST_ARGS="$PYTEST_ARGS $1"; shift ;;
esac
done
# Default image: Arch-based baker test image
: ${TEST_IMAGE:="hbw-system-test:latest"}
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONTAINER_NAME="hbw-system-test-container"
STAGING_DIR="/tmp/hbw-system-test"
IMAGE_NAME="hbw-system-test:latest"
# ---- Cleanup handler ----
cleanup() {
echo "[runner] Cleaning up..."
docker rm -f hbw-system-test-container 2>/dev/null || true
rm -rf "$STAGING_DIR"
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
[ "$TEST_DIR" = "tests/baker" ] && rm -rf "$STAGING_DIR"
}
trap cleanup EXIT
# ---- Step 1: Build the binary ----
build_binary() {
echo "[runner] Building workshop-baker (release)..."
local target="x86_64-unknown-linux-musl"
local binary="$SCRIPT_DIR/workshop-baker/target/$target/debug/workshop-baker"
if [ ! -f "$binary" ]; then
echo "[runner] Building static (musl) baker binary..."
cd "$SCRIPT_DIR/workshop-baker"
cargo build --release 2>&1
echo "[runner] Build complete."
cargo build -p workshop-baker --target "$target" 2>&1 | tail -n 1
fi
}
# ---- Step 2: Prepare staging directory ----
prepare_staging() {
echo "[runner] Preparing staging directory..."
rm -rf "$STAGING_DIR"
# Only needed for baker tests (hbw-system-test:latest mounts /workshop)
if [ "$TEST_DIR" = "tests/baker" ]; then
mkdir -p "$STAGING_DIR"
# Copy binary
cp "$SCRIPT_DIR/workshop-baker/target/release/workshop-baker" "$STAGING_DIR/"
cp "$SCRIPT_DIR/workshop-baker/target/x86_64-unknown-linux-musl/debug/workshop-baker" "$STAGING_DIR/"
chmod +x "$STAGING_DIR/workshop-baker"
# Copy examples if they exist
if [ -d "$SCRIPT_DIR/workshop-baker/examples" ]; then
cp -r "$SCRIPT_DIR/workshop-baker/examples" "$STAGING_DIR/examples"
else
mkdir -p "$STAGING_DIR/examples"
fi
# Copy llm.env if it exists
if [ -f "$SCRIPT_DIR/llm.env" ]; then
cp "$SCRIPT_DIR/llm.env" "$STAGING_DIR/llm.env"
fi
echo "[runner] Staging ready at $STAGING_DIR"
}
# ---- Step 3: Build Docker image ----
build_image() {
echo "[runner] Building Docker image: $IMAGE_NAME ..."
docker build -t "$IMAGE_NAME" -f "$SCRIPT_DIR/tests/Dockerfile" "$SCRIPT_DIR"
echo "[runner] Image built: $IMAGE_NAME"
}
# ---- Step 4: Run container ----
run_container() {
local mode="$1"
echo "[runner] Starting container in $mode mode..."
local tag="${TEST_IMAGE##*:}"
local filter=""
case "$tag" in
test-arch) filter="-k pacman" ;;
test-apt) filter="-k apt" ;;
test-dnf) filter="-k dnf" ;;
test-apk) filter="-k apk" ;;
esac
echo "[runner] Starting: $TEST_IMAGE (tests=$TEST_DIR${filter:+, filter=$filter})"
mkdir -p "$SCRIPT_DIR/tests/results"
if [ "$mode" = "manual" ]; then
docker run -it --rm \
--name hbw-system-test-container \
--privileged \
-v "$STAGING_DIR:/workshop:ro" \
-v "$SCRIPT_DIR/tests/results:/results" \
-e TEST_MODE=manual \
"$IMAGE_NAME"
local mounts="-v $SCRIPT_DIR:/src:ro"
local entrypoint=""
if [ "$TEST_DIR" = "tests/baker" ]; then
mounts="$mounts -v $STAGING_DIR:/workshop:ro"
entrypoint='--entrypoint ""'
fi
local cmd="sh -c \"python3 -m venv /tmp/v && . /tmp/v/bin/activate && \
pip install pytest pytest-timeout -q && \
exec python -m pytest $TEST_DIR $PYTEST_ARGS $filter\""
if [ "$MODE" = "manual" ]; then
docker run -it --rm --name "$CONTAINER_NAME" --privileged $entrypoint \
$mounts "$TEST_IMAGE"
else
docker run --rm \
--name hbw-system-test-container \
--privileged \
-v "$STAGING_DIR:/workshop:ro" \
-v "$SCRIPT_DIR/tests/results:/results" \
-e TEST_MODE=auto \
"$IMAGE_NAME" $PYTEST_ARGS
docker run --rm --name "$CONTAINER_NAME" --privileged $entrypoint \
$mounts -w /src \
"$TEST_IMAGE" "$cmd"
fi
}
# ---- Main flow ----
echo "============================================"
echo " HoneyBiscuitWorkshop System Test Runner"
echo " Mode: $MODE"
echo " HBW System Test Runner"
echo " Image: $TEST_IMAGE"
echo " Tests: $TEST_DIR"
echo "============================================"
build_binary
prepare_staging
build_image
run_container "$MODE"
run_container
echo "[runner] Done."
+102
View File
@@ -0,0 +1,102 @@
"""PM system tests driven by bare workshop-baker prebake."""
import os
import pytest
from probes import binary_exists
PACMAN_MIRROR = "https://mirrors.tuna.tsinghua.edu.cn/archlinux/$repo/os/$arch"
def _yml(pm, pkgs, mirror="", repo=None):
"""Build prebake.yml. repo is a list of lines (without lead indent)."""
m = ""
if mirror:
m += ' mirror: "' + mirror + '"\n'
if repo:
m += " repositories:\n"
for line in repo:
m += " " + line + "\n"
return """\
version: "1.0"
environment:
builder: "baremetal"
bootstrap:
user: "vulcan"
dependencies:
system:
{pm}:
packages: [{pkgs}]
{m}""".format(pm=pm, pkgs=pkgs, m=m)
# ── install ──────────────────────────────────────────────────────────
@pytest.mark.parametrize("pm_name,pkgs", [
("pacman", '"curl", "git"'),
("apt", '"curl", "git"'),
("dnf", '"curl", "git"'),
("apk", '"curl", "git"'),
])
def test_install(run_cmd, work_dir, pm_name, pkgs):
cfg = os.path.join(work_dir, "pm.yml")
mirror = PACMAN_MIRROR if pm_name == "pacman" else ""
with open(cfg, "w") as f:
f.write(_yml(pm_name, pkgs, mirror=mirror))
r = run_cmd(["/baker", "-p", "t", "-b", "1", "-u", "testuser",
"bare", "prebake", cfg], timeout=120)
assert r["success"], "[{}] prebake: {}".format(pm_name, r["stderr"][:300])
for pkg in ["curl", "git"]:
assert binary_exists("/usr/bin/" + pkg), "[{}] {} missing".format(pm_name, pkg)
@pytest.mark.parametrize("pm_name", ["pacman", "apt", "dnf", "apk"])
def test_reinstall(run_cmd, work_dir, pm_name):
cfg = os.path.join(work_dir, "pm.yml")
mirror = PACMAN_MIRROR if pm_name == "pacman" else ""
with open(cfg, "w") as f:
f.write(_yml(pm_name, '"curl"', mirror=mirror))
cmd = ["/baker", "-p", "t", "-b", "1", "-u", "testuser", "bare", "prebake", cfg]
r1 = run_cmd(cmd, timeout=120)
assert r1["success"], "[{}] first install failed".format(pm_name)
r2 = run_cmd(cmd, timeout=120)
assert r2["success"], "[{}] reinstall failed".format(pm_name)
# ── change_mirror ────────────────────────────────────────────────────
@pytest.mark.parametrize("pm_name", ["pacman", "apt", "dnf", "apk"])
def test_mirror(run_cmd, work_dir, pm_name):
cfg = os.path.join(work_dir, "pm.yml")
mirror = PACMAN_MIRROR if pm_name == "pacman" else ""
with open(cfg, "w") as f:
f.write(_yml(pm_name, '"which"', mirror=mirror))
r = run_cmd(["/baker", "-p", "t", "-b", "1", "-u", "testuser",
"bare", "prebake", cfg], timeout=120)
assert r["success"], "[{}] mirror: {}".format(pm_name, r["stderr"][:300])
assert binary_exists("/usr/bin/which"), "[{}] which not installed".format(pm_name)
# ── add_repository ───────────────────────────────────────────────────
@pytest.mark.parametrize("pm_name,repo_lines,result_file", [
("pacman", ['- name: custom', ' server: "https://repo.example.com/$arch"'],
"/etc/pacman.conf"),
("apt", ['- url: "https://repo.example.com/debian"', ' distro: bookworm'],
"/etc/apt/sources.list.d/custom.list"),
("dnf", ['- name: custom', ' baseurl: "https://repo.example.com/fedora"'],
"/etc/yum.repos.d/custom.repo"),
("apk", ['- url: "https://repo.example.com/v3.19/main"'],
"/etc/apk/repositories"),
])
@pytest.mark.xfail(reason="fake repo URL, package install may fail", strict=False)
def test_add_repo(run_cmd, work_dir, pm_name, repo_lines, result_file):
cfg = os.path.join(work_dir, "pm.yml")
mirror = PACMAN_MIRROR if pm_name == "pacman" else ""
with open(cfg, "w") as f:
f.write(_yml(pm_name, '"curl"', mirror=mirror, repo=repo_lines))
r = run_cmd(["/baker", "-p", "t", "-b", "1", "-u", "testuser",
"bare", "prebake", cfg], timeout=120)
# Prebake may fail if repo URL is unreachable — check only the config file
# was written by add_repository before the (possibly failing) install step.
assert binary_exists(result_file), \
"[{}] repo file missing: {}".format(pm_name, result_file)
+6
View File
@@ -0,0 +1,6 @@
FROM docker.1ms.run/library/alpine:3.19
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apk/repositories
RUN apk add --no-cache python3 py3-pip bash
COPY workshop-baker/target/x86_64-unknown-linux-musl/debug/workshop-baker /baker
RUN chmod +x /baker
ENTRYPOINT ["/bin/sh", "-c"]
+6
View File
@@ -0,0 +1,6 @@
FROM docker.1ms.run/library/debian:bookworm-slim
RUN sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null; sed -i 's|security.debian.org|mirrors.tuna.tsinghua.edu.cn/debian-security|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null; true
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends python3 python3-venv ca-certificates 2>&1 | tail -n 2
COPY workshop-baker/target/x86_64-unknown-linux-musl/debug/workshop-baker /baker
RUN chmod +x /baker
ENTRYPOINT ["/bin/sh", "-c"]
+6
View File
@@ -0,0 +1,6 @@
FROM docker.1ms.run/library/fedora:43
RUN sed -e 's|^metalink=|#metalink=|g' -e 's|^#baseurl=http://download.example/pub/fedora/linux|baseurl=https://mirrors.tuna.tsinghua.edu.cn/fedora|g' -i.bak /etc/yum.repos.d/fedora.repo /etc/yum.repos.d/fedora-updates.repo
RUN dnf install -y python3 2>&1 | tail -n 2
COPY workshop-baker/target/x86_64-unknown-linux-musl/debug/workshop-baker /baker
RUN chmod +x /baker
ENTRYPOINT ["/bin/sh", "-c"]
+2
View File
@@ -0,0 +1,2 @@
FROM docker.1ms.run/library/archlinux:latest
RUN echo 'Server = https://mirrors.tuna.tsinghua.edu.cn/archlinux/$repo/os/$arch' > /etc/pacman.d/mirrorlist
+13 -2
View File
@@ -2,18 +2,24 @@
Environment probes for verifying system state in workshop-baker tests.
Each probe is a standalone function that can be called from any test.
Probes return bool or raise AssertionError with descriptive message.
Probes return bool or Optional[int/str]; they do not raise on failure.
A probe accepts an optional *runner* callable (default ``subprocess.run``)
that decouples it from the execution context (host or Docker).
"""
from .user import user_exists, user_uid, user_gid, user_in_group, user_shell
from .file import (
file_exists,
file_absent,
dir_exists,
dir_absent,
file_permissions,
file_contains,
file_owned_by,
file_group,
)
from .package import package_installed, package_version
from .package import binary_exists, binary_on_path, package_installed, package_version
from .process import process_running, process_count
__all__ = [
@@ -23,12 +29,17 @@ __all__ = [
"user_in_group",
"user_shell",
"file_exists",
"file_absent",
"dir_exists",
"dir_absent",
"file_permissions",
"file_contains",
"file_owned_by",
"file_group",
"package_installed",
"package_version",
"binary_exists",
"binary_on_path",
"process_running",
"process_count",
]
+10
View File
@@ -41,3 +41,13 @@ def file_group(path: str) -> str:
"""Get the group owner of a file."""
st = os.stat(path)
return grp.getgrgid(st.st_gid).gr_name
def file_absent(path: str) -> bool:
"""Check that *path* does not exist."""
return not os.path.lexists(path)
def dir_absent(path: str) -> bool:
"""Check that *path* does not exist or is not a directory."""
return not os.path.isdir(path)
+36 -28
View File
@@ -1,34 +1,42 @@
"""Package installation probes for Arch Linux (pacman)."""
"""Package / binary existence probes (cross-distro).
Each probe accepts an optional *runner* callable with the same signature
as ``subprocess.run``. Defaults to running on the host; for Docker tests,
pass a runner that wraps ``docker run --rm <image>``.
"""
import subprocess
from typing import Any, Callable
Runner = Callable[..., Any]
def package_installed(package_name: str) -> bool:
"""Check if a pacman package is installed."""
try:
result = subprocess.run(
["pacman", "-Q", package_name],
capture_output=True,
text=True,
)
return result.returncode == 0
except FileNotFoundError:
return False
def _run(cmd: list[str], runner: Runner = subprocess.run, **kw: Any) -> Any:
opts = {"capture_output": True, "text": True, "timeout": 30}
opts.update(kw)
return runner(cmd, **opts)
def package_version(package_name: str) -> str | None:
"""Get installed version of a package, or ``None`` if not installed."""
try:
result = subprocess.run(
["pacman", "-Q", package_name],
capture_output=True,
text=True,
)
if result.returncode == 0:
# Output format: "package_name version"
parts = result.stdout.strip().split()
if len(parts) >= 2:
return parts[1]
return None
except FileNotFoundError:
return None
def _succeeded(r: Any) -> bool:
""".returncode==0 for CompletedProcess, or ["success"] for dict."""
if hasattr(r, "returncode"):
return r.returncode == 0
return bool(r.get("success", False))
def binary_exists(path: str, runner: Runner = subprocess.run) -> bool:
"""Check if a binary exists at *path* (e.g. ``/usr/bin/curl``).
Uses ``test -f`` (POSIX, guaranteed available).
"""
return _succeeded(_run(["test", "-f", path], runner))
def binary_on_path(name: str, runner: Runner = subprocess.run) -> bool:
"""Check if a command is on PATH (via ``command -v``)."""
return _succeeded(_run(["sh", "-c", "command -v " + name], runner))
# Backward-compatible aliases
package_installed = binary_exists
package_version = binary_on_path # used in __init__ export, now binary-based
+12 -8
View File
@@ -13,15 +13,17 @@ def user_exists(username: str) -> bool:
return False
def user_uid(username: str) -> int:
"""Get the UID of a user. Raises KeyError if user doesn't exist."""
def user_uid(username: str) -> int | None:
try:
return pwd.getpwnam(username).pw_uid
except KeyError:
return None
def user_gid(username: str) -> int:
"""Get the primary GID of a user. Raises KeyError if user doesn't exist."""
def user_gid(username: str) -> int | None:
try:
return pwd.getpwnam(username).pw_gid
except KeyError:
return None
def user_in_group(username: str, groupname: str) -> bool:
"""Check if a user is a member of a group."""
@@ -32,6 +34,8 @@ def user_in_group(username: str, groupname: str) -> bool:
return False
def user_shell(username: str) -> str:
"""Get the login shell of a user. Raises KeyError if user doesn't exist."""
def user_shell(username: str) -> str | None:
try:
return pwd.getpwnam(username).pw_shell
except KeyError:
return None
+92 -174
View File
@@ -691,16 +691,6 @@ 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"
@@ -1101,21 +1091,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[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"
@@ -1215,8 +1190,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1226,9 +1203,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -2285,17 +2264,6 @@ 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"
@@ -2399,6 +2367,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -2414,22 +2383,6 @@ 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"
@@ -2449,11 +2402,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2806,18 +2757,18 @@ dependencies = [
"fastrand",
"futures-io",
"futures-util",
"hostname",
"httpdate",
"idna",
"mime",
"native-tls",
"nom",
"percent-encoding",
"quoted_printable",
"rustls",
"socket2",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"url",
"webpki-roots",
]
[[package]]
@@ -2865,6 +2816,12 @@ version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchit"
version = "0.8.4"
@@ -2942,23 +2899,6 @@ 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"
@@ -3269,56 +3209,12 @@ 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"
@@ -3646,6 +3542,61 @@ dependencies = [
"prost",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.42"
@@ -3791,29 +3742,26 @@ 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",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
@@ -3821,6 +3769,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -3871,6 +3820,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix"
version = "1.1.4"
@@ -3905,7 +3860,7 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3"
dependencies = [
"openssl-probe 0.1.6",
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
@@ -3917,6 +3872,7 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
dependencies = [
"web-time",
"zeroize",
]
@@ -3998,7 +3954,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.10.1",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -4291,27 +4247,6 @@ 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"
@@ -4479,16 +4414,6 @@ 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"
@@ -4845,12 +4770,6 @@ 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"
@@ -4959,6 +4878,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.4"
@@ -5049,17 +4978,6 @@ 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"
+2 -2
View File
@@ -28,7 +28,7 @@ env_logger = "0.11.8"
glob = "0.3.2"
futures-util = "0.3.31"
lazy_static = "1.5.0"
lettre = { version = "0.11", features = ["tokio1-native-tls"] }
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls", "rustls-tls", "builder", "smtp-transport"] }
log = "0.4.28"
minijinja = "2.14.0"
regex = "1.12.2"
@@ -47,7 +47,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"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
which = "8.0.2"
gix = "0.81.0"
sha2 = "0.10"
@@ -19,7 +19,7 @@ pub enum GitVersion {
/// Format: `url` (no version), `url@tag` (single version), `url@tag:commit` (mixed version).
/// Returns (base_url, GitVersion).
pub fn parse_git_url(url: &str) -> (String, GitVersion) {
if let Some((base, version)) = url.split_once('@') {
if let Some((base, version)) = url.rsplit_once('@') {
if version.contains(":") {
let (tag, commit) = version.split_once(':').unwrap();
return (
+4 -3
View File
@@ -154,8 +154,9 @@ pub async fn prebake(
.map(|c| c.repology_endpoint)
.unwrap_or(workshop_engine::RepologyEndpoint::Default);
let osinfo = os_info::get();
let detected_pm = workshop_engine::pm::detect(&osinfo).map(|pm| pm.name().to_string());
let pm_name = dependencies.system
.as_ref()
.and_then(|sys| sys.keys().next().cloned());
let upm_sys_deps = if let Some(user) = &dependencies.user {
if stage <= PrebakeStage::DepsSystem {
@@ -170,7 +171,7 @@ pub async fn prebake(
let mut system_config = dependencies.system.clone().unwrap_or_default();
if !upm_sys_deps.deps.is_empty() {
if let Some(pm_name) = &detected_pm {
if let Some(ref pm_name) = pm_name {
for (upm_name, sys_deps) in &upm_sys_deps.deps {
let resolved = workshop_engine::repology::resolve_package_names(
&sys_deps.packages,
+3
View File
@@ -61,4 +61,7 @@ pub enum BootstrapError {
#[error("Failed to create user {0}: {1}")]
UserCreationFailed(String, String),
#[error("Invalid bootstrap configuration: {0}")]
InvalidConfig(String),
}
+89 -44
View File
@@ -53,22 +53,49 @@ pub async fn bootstrap(
let bootstrap_config = config.bootstrap.clone().unwrap_or_default();
if let Some(custom_bootstrap) = bootstrap_config.custom {
if let Some(raw) = bootstrap_config.custom {
match BootstrapSource::parse(&raw) {
BootstrapSource::Inline(script) => {
if ctx.dry_run {
log::info!(
"[DRY_RUN] User provided bootstrap script:\n{}",
custom_bootstrap
);
log::info!("[DRY_RUN] User provided inline bootstrap script:\n{}", script);
return Ok(());
}
// TODO: Parse custom bootstrap URL and download them
log::debug!("User provided bootstrap script:\n{}", custom_bootstrap);
log::debug!("Using inline bootstrap script ({} bytes)", script.len());
if ctx.standalone {
write_bootstrap_content(&bootstrap_path, custom_bootstrap)?;
write_bootstrap_content(&bootstrap_path, script)?;
} else {
// Requires PIP(Pipeline Implicit Parameters) to be implemented
unimplemented!("Permission check is not implemented, failing...");
}
}
BootstrapSource::Workspace(relpath) => {
// Project-relative path, resolved by bakerd against the project root.
// Routing path relpath through bakerd's workspace manager ensures
// proper sandboxing and access control.
log::info!("Custom bootstrap from workspace: {}", relpath);
unimplemented!("Workspace-relative bootstrap requires bakerd's workspace manager");
}
BootstrapSource::Server(name) => {
// Script provided by the bake server (bakerd), keyed by name.
// The server maintains a registry of trusted bootstrap scripts.
log::info!("Custom bootstrap from server: {}", name);
unimplemented!("Server-provided bootstrap requires bakerd's script registry");
}
BootstrapSource::Http(url) => {
// HTTP download, similar to finalize::plugin::fetch::fetch_http_plugin.
// Should support: checksum verification, retry, timeout.
log::info!("Custom bootstrap from URL: {}", url);
log::info!(" Target: {}", bootstrap_path.display());
unimplemented!("HTTP bootstrap fetching requires bakerd-level network infrastructure");
}
BootstrapSource::File(path) => {
// Local file copy. This is the simplest case — we can implement
// this directly without bakerd infrastructure.
log::info!("Custom bootstrap from local file: {}", path);
tokio::fs::copy(&path, &bootstrap_path).await
.map_err(BootstrapError::IOError)?;
}
}
} else {
let script = generate_bootstrap(&bootstrap_config, config, osinfo, ctx.standalone)?;
if ctx.dry_run {
@@ -102,6 +129,52 @@ fn write_bootstrap_content(bootstrap_path: &Path, content: String) -> Result<(),
Ok(())
}
/// Source type for a custom bootstrap script.
///
/// Mirrors the scheme-based resolution in `finalize::plugin::fetch` URL parsing,
/// and supports the schemes defined in the prebake.yml.tmpl spec:
/// `workspace://`, `file://`, `server://`, `https?://`, plus inline content.
enum BootstrapSource {
/// Inline script content (no scheme match).
Inline(String),
/// Project-relative file: `workspace://path/to/bootstrap.sh`
Workspace(String),
/// Script provided by bake server: `server://script-name`
Server(String),
/// HTTP(S) download: `https://example.com/bootstrap.sh`
Http(String),
/// Local file path: `file:///bin/bootstrap.sh`
File(String),
}
impl BootstrapSource {
/// Parses a raw custom bootstrap string into its source type.
///
/// Scheme detection follows the template spec:
/// - `workspace://` → Workspace
/// - `file://` → File (local path)
/// - `server://` → Server (bakerd registry)
/// - `http://`/`https://`→ Http
/// - No recognized scheme → Inline
fn parse(raw: &str) -> Self {
if let Some(rest) = raw.strip_prefix("workspace://") {
return BootstrapSource::Workspace(rest.to_string());
}
if let Some(path) = raw.strip_prefix("file://") {
return BootstrapSource::File(path.to_string());
}
if let Some(name) = raw.strip_prefix("server://") {
return BootstrapSource::Server(name.to_string());
}
if let Some(url) = raw.strip_prefix("https://")
.or_else(|| raw.strip_prefix("http://"))
{
return BootstrapSource::Http(url.to_string());
}
BootstrapSource::Inline(raw.to_string())
}
}
/// Generates a bootstrap script based on configuration and target OS.
///
/// The generated script performs the following setup tasks:
@@ -119,7 +192,7 @@ fn write_bootstrap_content(bootstrap_path: &Path, content: String) -> Result<(),
///
/// * `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
/// * `_osinfo` - Operating system information (reserved)
///
/// # Returns
///
@@ -134,7 +207,7 @@ fn write_bootstrap_content(bootstrap_path: &Path, content: String) -> Result<(),
fn generate_bootstrap(
bootstrap_config: &Bootstrap,
config: &PrebakeConfig,
osinfo: &os_info::Info,
_osinfo: &os_info::Info,
standalone: bool,
) -> Result<String, BootstrapError> {
let mut script = String::new();
@@ -190,7 +263,7 @@ fn generate_bootstrap(
// Setup privilege executor
script.push_str("# Setup privilege executor\n");
let pkg_manager = get_package_manager(config, osinfo);
let pkg_manager = get_package_manager(config);
if which("sudo").is_ok() {
if let Some(sudoers) = &bootstrap_config.sudoers {
if standalone {
@@ -242,60 +315,32 @@ fn generate_bootstrap(
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
/// Selects the package manager from the first configured system dependency.
///
/// # 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.
/// Returns `Some(Box<dyn PackageManager>)` if a configured package manager is known,
/// or `None` if no package manager is configured or recognized.
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
);
log::info!("Using package manager: {}", name);
return Some(pm);
}
log::warn!("No valid package manager found in configuration");
log::warn!("No known package manager found in dependencies.system");
None
})
}
+12 -12
View File
@@ -6,7 +6,7 @@ use workshop_engine::{Engine, EventSender, error::DependencyError, pm};
/// Handles system dependency installation for the target platform.
///
/// This function:
/// - Detects the operating system and package manager
/// - Selects the package manager from the configured dependencies
/// - Optionally changes the package manager mirror
/// - Optionally adds custom repositories
/// - Updates the package index
@@ -19,21 +19,21 @@ pub async fn depssystem(
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);
// Select package manager from the first configured system dependency
let package_manager = config
.keys()
.find_map(|name| pm::select(name))
.ok_or_else(|| {
log::error!(
"No known package manager configured in dependencies.system for {}",
&osinfo
);
DependencyError::UnsupportedPlatform(osinfo.clone())
})?;
log::info!("Detected package manager: {}", package_manager.name());
log::info!("Using 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(());
};
let pm_config = &config[package_manager.name()];
// Change mirror
if let Some(mirror) = &pm_config.mirror {
+2 -2
View File
@@ -88,13 +88,13 @@ impl StageInfo {
///
/// # Example
/// ```
/// # use workshop_baker::types::buildstatus::{StageInfo, StagePhase, StageResult};
/// # use workshop_baker::types::buildstatus::{StageInfo, StagePhase, StageOutcome};
/// # use chrono::Utc;
/// let info = StageInfo {
/// name: "bootstrap".to_string(),
/// phase: StagePhase::Prebake,
/// substage: "bootstrap".to_string(),
/// result: StageResult::Success,
/// result: StageOutcome::Success,
/// duration_ms: 1000,
/// started_at: Utc::now(),
/// finished_at: Utc::now(),
+52 -21
View File
@@ -1,8 +1,9 @@
use os_info::Info;
use serde_yaml::Value;
use tokio::process::Command;
// TODO: apt
// mod apt;
mod apk;
mod apt;
mod dnf;
mod pacman;
/// Trait for package manager implementations.
@@ -13,8 +14,6 @@ pub trait PackageManager {
/// Returns the name of the package manager.
fn name(&self) -> &'static str;
/// Determines whether this package manager supports the given distribution.
fn adopt(&self, distro: &Info) -> bool;
/// Returns the binary name of the package manager.
fn binary(&self) -> &'static str;
@@ -25,12 +24,6 @@ pub trait PackageManager {
/// Generates commands to install the specified 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!()}
/// Generates commands to change the primary mirror repository.
fn change_mirror(&self, repo: &str, osinfo: Option<&Info>) -> Vec<Command>;
@@ -42,22 +35,15 @@ pub trait PackageManager {
/// Returns a list of all available package managers.
pub fn all_managers() -> Vec<Box<dyn PackageManager>> {
vec![
// Box::new(apt::Apt),
Box::new(apk::Apk),
Box::new(apt::Apt),
Box::new(dnf::Dnf),
Box::new(pacman::Pacman),
// Box::new(dnf::Dnf),
]
}
/// Detects the appropriate package manager for the given distribution.
pub fn detect(distro: &os_info::Info) -> Option<Box<dyn PackageManager>> {
all_managers().into_iter().find(|pm| pm.adopt(distro))
}
/// Selects a package manager by name.
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)
}
@@ -72,15 +58,60 @@ mod tests {
}
#[test]
fn test_select_known_manager() {
fn test_select_pacman() {
let pm = select("pacman");
assert!(pm.is_some());
assert_eq!(pm.unwrap().name(), "pacman");
}
#[test]
fn test_select_apt() {
let pm = select("apt");
assert!(pm.is_some());
assert_eq!(pm.unwrap().name(), "apt");
}
#[test]
fn test_select_unknown_manager() {
let pm = select("nonexistent_pm");
assert!(pm.is_none());
}
#[test]
fn test_binary_pacman() {
let pm = select("pacman").unwrap();
assert_eq!(pm.binary(), "pacman");
}
#[test]
fn test_binary_apt() {
let pm = select("apt").unwrap();
assert_eq!(pm.binary(), "apt-get");
}
#[test]
fn test_select_dnf() {
let pm = select("dnf");
assert!(pm.is_some());
assert_eq!(pm.unwrap().name(), "dnf");
}
#[test]
fn test_select_apk() {
let pm = select("apk");
assert!(pm.is_some());
assert_eq!(pm.unwrap().name(), "apk");
}
#[test]
fn test_binary_dnf() {
let pm = select("dnf").unwrap();
assert_eq!(pm.binary(), "dnf");
}
#[test]
fn test_binary_apk() {
let pm = select("apk").unwrap();
assert_eq!(pm.binary(), "apk");
}
}
+272
View File
@@ -0,0 +1,272 @@
use crate::pm::PackageManager;
use os_info::Info;
use serde::Deserialize;
use serde_yaml::Value;
use tokio::process::Command;
#[derive(Clone)]
pub struct Apk;
/// Repository configuration for apk (Alpine Linux).
#[derive(Deserialize, Clone)]
struct ApkRepo {
/// Full repository URL (e.g., `https://dl-cdn.alpinelinux.org/alpine/v3.19/main`).
url: String,
/// Whether this is a community repo (adds @community tag, optional).
#[serde(default)]
community: bool,
/// GPG key URL to download and trust (optional).
#[serde(default)]
key_url: Option<String>,
}
impl PackageManager for Apk {
fn name(&self) -> &'static str {
"apk"
}
fn binary(&self) -> &'static str {
"apk"
}
fn update(&self) -> Vec<Command> {
let mut cmd = Command::new("apk");
cmd.arg("update");
vec![cmd]
}
fn install(&self, package: &[String]) -> Vec<Command> {
let mut cmd = Command::new("apk");
cmd.arg("add");
cmd.args(package);
vec![cmd]
}
/// Changes the primary apk mirror by replacing /etc/apk/repositories.
///
/// Alpine's repository config is a simple line-per-mirror file.
/// This function replaces the entire file with the specified mirror URL.
fn change_mirror(&self, repo: &str, _osinfo: Option<&Info>) -> Vec<Command> {
let script = format!(
"cat > /etc/apk/repositories << 'APK_EOF'\n{}\nAPK_EOF",
repo
);
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(script);
vec![cmd]
}
/// Adds a custom apk repository by appending a line to /etc/apk/repositories
/// and optionally installing a GPG key.
fn add_repository(&self, repo: &Value, _osinfo: Option<&Info>) -> Vec<Command> {
let repos: Vec<ApkRepo> = match serde_yaml::from_value(repo.clone()) {
Ok(r) => r,
Err(e) => {
log::error!("Failed to parse apk repository: {}", e);
return Vec::new();
}
};
let mut cmds: Vec<Command> = vec![];
for repo_cfg in &repos {
// Download and trust GPG key if provided
if let Some(key_url) = &repo_cfg.key_url {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(format!(
"wget -qO- '{}' | apk keys --stdin",
key_url
));
cmds.push(cmd);
}
// Append the repository URL to /etc/apk/repositories
let line = if repo_cfg.community {
format!("{}@community\n", repo_cfg.url)
} else {
format!("{}\n", repo_cfg.url)
};
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(format!(
"echo '{}' >> /etc/apk/repositories",
line.trim()
));
cmds.push(cmd);
}
cmds
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_name() {
assert_eq!(Apk.name(), "apk");
}
#[test]
fn test_binary() {
assert_eq!(Apk.binary(), "apk");
}
#[test]
fn test_update_command_contents() {
let cmds = Apk.update();
let cmd = cmds[0].as_std();
assert_eq!(cmd.get_program().to_string_lossy(), "apk");
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 cmds = Apk.install(&["curl".to_string()]);
let cmd = cmds[0].as_std();
assert_eq!(cmd.get_program().to_string_lossy(), "apk");
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(args, vec!["add", "curl"]);
}
#[test]
fn test_install_multiple_packages() {
let cmds = Apk.install(&["curl".to_string(), "git".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!["add", "curl", "git"]);
}
#[test]
fn test_change_mirror_command() {
let cmds = Apk.change_mirror(
"https://mirrors.tuna.tsinghua.edu.cn/alpine/v3.19/main",
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/apk/repositories"));
assert!(full_arg.contains("mirrors.tuna.tsinghua.edu.cn"));
}
#[test]
fn test_add_repository_simple() {
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
serde_yaml::Value::Mapping(
[(
serde_yaml::Value::String("url".to_string()),
serde_yaml::Value::String(
"https://dl-cdn.alpinelinux.org/alpine/v3.19/main".to_string(),
),
)]
.into_iter()
.collect(),
),
]))
.unwrap();
let cmds = Apk.add_repository(&repo_yaml, None);
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("echo"));
assert!(full_arg.contains(">> /etc/apk/repositories"));
assert!(full_arg.contains("dl-cdn.alpinelinux.org"));
}
#[test]
fn test_add_repository_with_key() {
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
serde_yaml::Value::Mapping(
[
(
serde_yaml::Value::String("url".to_string()),
serde_yaml::Value::String(
"https://dl-cdn.alpinelinux.org/alpine/v3.19/main".to_string(),
),
),
(
serde_yaml::Value::String("key_url".to_string()),
serde_yaml::Value::String(
"https://example.com/alpine-key".to_string(),
),
),
]
.into_iter()
.collect(),
),
]))
.unwrap();
let cmds = Apk.add_repository(&repo_yaml, None);
// Should have 2 commands: key download, repo append
assert_eq!(cmds.len(), 2);
let key_cmd = cmds[0].as_std();
let args: Vec<String> = key_cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let full_arg = args.join(" ");
assert!(full_arg.contains("wget"));
assert!(full_arg.contains("apk keys"));
}
#[test]
fn test_add_repository_community() {
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
serde_yaml::Value::Mapping(
[
(
serde_yaml::Value::String("url".to_string()),
serde_yaml::Value::String(
"https://dl-cdn.alpinelinux.org/alpine/v3.19/community"
.to_string(),
),
),
(
serde_yaml::Value::String("community".to_string()),
serde_yaml::Value::Bool(true),
),
]
.into_iter()
.collect(),
),
]))
.unwrap();
let cmds = Apk.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("@community"));
}
#[test]
fn test_add_repository_invalid_yaml_returns_empty() {
let invalid_yaml = serde_yaml::Value::String("invalid".to_string());
let cmds = Apk.add_repository(&invalid_yaml, None);
assert!(cmds.is_empty());
}
}
+356 -54
View File
@@ -1,15 +1,48 @@
use crate::pm::PackageManager;
use os_info::{Info, Version};
use serde::Deserialize;
use serde_yaml::Value;
use tokio::process::Command;
#[derive(Clone)]
pub struct Apt;
#[derive(Clone)]
struct AptRepositories {
/// Repository configuration for apt.
#[derive(Deserialize, Clone)]
struct AptRepo {
/// The deb line URL (e.g., `https://repo.example.com/ubuntu`)
url: String,
key: Option<String>,
/// Distribution codename override (e.g., `focal`, `bookworm`).
/// If not set, extracted from os_info at runtime.
#[serde(default)]
distro: Option<String>,
/// Components (e.g., `main`, `universe`, `contrib`).
/// Defaults to `["main"]` if not specified.
#[serde(default = "default_components")]
components: Vec<String>,
/// GPG key URL to download and install.
#[serde(default)]
key_url: Option<String>,
/// GPG key ID to fetch from keyserver.
#[serde(default)]
key_id: Option<String>,
/// Path to save the GPG keyring file.
#[serde(default = "default_keyring_dir")]
keyring_path: Option<String>,
/// Architecture constraint (e.g., `amd64`, `arm64`).
#[serde(default)]
arch: Option<String>,
/// Whether this is a source repo (`deb-src`).
#[serde(default)]
source: bool,
}
fn default_components() -> Vec<String> {
vec!["main".to_string()]
}
fn default_keyring_dir() -> Option<String> {
Some("/usr/share/keyrings".to_string())
}
impl PackageManager for Apt {
@@ -17,49 +50,200 @@ impl PackageManager for Apt {
"apt"
}
fn adopt(&self, distro: &Info) -> bool {
return false;
// todo!();
}
fn binary(&self) -> &'static str {
"apt"
"apt-get"
}
fn update(&self) -> Command {
let mut cmd = Command::new("apt");
fn update(&self) -> Vec<Command> {
let mut cmd = Command::new("apt-get");
cmd.arg("update");
cmd
vec![cmd]
}
fn install(&self, package: &[String]) -> Command {
let mut cmd = Command::new("apt");
fn install(&self, package: &[String]) -> Vec<Command> {
let mut cmd = Command::new("apt-get");
cmd.arg("install");
cmd.arg("-y"); // 自动确认
cmd.arg("-y");
cmd.args(package);
cmd
vec![cmd]
}
fn change_mirror(&self, repo: &str, osinfo: Option<&Info>) -> Command {
todo!();
/// Changes the primary apt mirror by replacing the URL in /etc/apt/sources.list.
///
/// Uses sed to replace the existing mirror URL with the user-specified repo URL.
fn change_mirror(&self, repo: &str, _osinfo: Option<&Info>) -> Vec<Command> {
let script = format!(
"sed -i 's|^deb http[^ ]*|deb {}|' /etc/apt/sources.list",
repo
);
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(script);
vec![cmd]
}
/// Adds a custom apt repository by creating a .list file in /etc/apt/sources.list.d/
/// and optionally installing a GPG key.
fn add_repository(&self, repo: &Value, osinfo: Option<&Info>) -> Vec<Command> {
todo!();
let repos: Vec<AptRepo> = match serde_yaml::from_value(repo.clone()) {
Ok(r) => r,
Err(e) => {
log::error!("Failed to parse apt repository: {}", e);
return Vec::new();
}
};
let codename = repos
.first()
.and_then(|r| r.distro.clone())
.or_else(|| extract_codename(osinfo));
let codename = match codename {
Some(c) => c,
None => {
log::error!(
"Cannot determine distribution codename for apt repository, \
set 'distro' field in config or provide os_info"
);
return Vec::new();
}
};
let mut cmds: Vec<Command> = vec![];
for repo_cfg in &repos {
// Install GPG key if configured
if let Some(key_url) = &repo_cfg.key_url {
let keyring = repo_cfg
.keyring_path
.clone()
.unwrap_or_else(|| "/usr/share/keyrings".to_string());
let keyring_name = format!("{}-keyring.gpg", sanitize_repo_name(&repo_cfg.url));
let keyring_path = format!("{}/{}", keyring, keyring_name);
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(format!(
"curl -fsSL '{}' | gpg --dearmor -o '{}' && chmod 644 '{}'",
key_url, keyring_path, keyring_path
));
cmds.push(cmd);
}
if let Some(key_id) = &repo_cfg.key_id {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(format!(
"gpg --keyserver keyserver.ubuntu.com --recv-keys {} && \
gpg --export {} | gpg --dearmor > /usr/share/keyrings/{}-keyring.gpg",
key_id,
key_id,
sanitize_repo_name(&repo_cfg.url)
));
cmds.push(cmd);
}
// Build the deb line
let arch_part = repo_cfg
.arch
.as_ref()
.map(|a| format!("[arch={}] ", a))
.unwrap_or_default();
let keyring_name = if repo_cfg.key_url.is_some() || repo_cfg.key_id.is_some() {
format!(
" [signed-by=/usr/share/keyrings/{}-keyring.gpg]",
sanitize_repo_name(&repo_cfg.url)
)
} else {
String::new()
};
let deb_type = if repo_cfg.source { "deb-src" } else { "deb" };
let components = repo_cfg.components.join(" ");
let deb_line = format!(
"{deb_type} {arch_part}{keyring_name} {url} {codename} {components}\n",
deb_type = deb_type,
arch_part = arch_part,
keyring_name = keyring_name,
url = repo_cfg.url,
codename = codename,
components = components,
);
// Write the .list file
let list_name = format!(
"/etc/apt/sources.list.d/{}.list",
sanitize_repo_name(&repo_cfg.url)
);
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(format!(
"cat > '{}' << 'APT_EOF'\n{}APT_EOF",
list_name, deb_line
));
cmds.push(cmd);
}
cmds
}
}
/// Sanitizes a URL to a safe filename component.
fn sanitize_repo_name(url: &str) -> String {
url.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_start_matches("ppa:")
.replace(|c: char| !c.is_alphanumeric() && c != '.' && c != '-', "_")
.trim_matches('_')
.to_string()
}
/// Extracts the distribution codename from os_info, falling back to version-based heuristics.
fn extract_codename(info: Option<&Info>) -> Option<String> {
let info = info?;
// 1. Direct codename from os_info (supported in newer versions)
if let Some(codename) = info.codename()
&& !codename.is_empty()
{
return Some(codename.to_string());
}
// 2. Try to infer from version string
match info.version() {
Version::Custom(v)
if !v.chars().next().map(|c| c.is_numeric()).unwrap_or(true)
=> {
// Some systems put codename in Custom field
return Some(v.clone());
}
Version::Semantic(major, minor, _) => {
// For Ubuntu/Debian, fall back to version number
return Some(format!("{}.{}", major, minor));
}
_ => {}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_name() {
assert_eq!(Apt.name(), "apt");
}
#[test]
fn test_binary() {
assert_eq!(Apt.binary(), "apt-get");
}
#[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 cmds = Apt.update();
let cmd = cmds[0].as_std();
assert_eq!(cmd.get_program().to_string_lossy(), "apt-get");
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
@@ -69,47 +253,165 @@ mod tests {
#[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 cmds = Apt.install(&["curl".to_string()]);
let cmd = cmds[0].as_std();
assert_eq!(cmd.get_program().to_string_lossy(), "apt-get");
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(args, vec!["install", "-y", "curl"]);
}
#[test]
fn test_install_multiple_packages() {
let cmds = Apt.install(&["curl".to_string(), "git".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!["install", "-y", "curl", "git"]);
}
/// 辅助函数:从 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());
}
#[test]
fn test_change_mirror_command() {
let cmds = Apt.change_mirror("http://mirror.tuna.tsinghua.edu.cn/ubuntu", 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("sed -i"));
assert!(full_arg.contains("/etc/apt/sources.list"));
assert!(full_arg.contains("mirror.tuna.tsinghua.edu.cn"));
}
// 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));
}
_ => {}
#[test]
fn test_add_repository_simple_deb_line() {
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
serde_yaml::Value::Mapping(
[
(
serde_yaml::Value::String("url".to_string()),
serde_yaml::Value::String(
"https://repo.example.com/ubuntu".to_string(),
),
),
(
serde_yaml::Value::String("distro".to_string()),
serde_yaml::Value::String("focal".to_string()),
),
(
serde_yaml::Value::String("components".to_string()),
serde_yaml::Value::Sequence(vec![
serde_yaml::Value::String("main".to_string()),
serde_yaml::Value::String("universe".to_string()),
]),
),
]
.into_iter()
.collect(),
),
]))
.unwrap();
let cmds = Apt.add_repository(&repo_yaml, None);
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 >"));
assert!(full_arg.contains("sources.list.d"));
assert!(full_arg.contains("repo.example.com"));
assert!(full_arg.contains("focal"));
assert!(full_arg.contains("main universe"));
}
// 3. 如果 os_info 没提供,且是常见发行版,可以尝试根据 Type 猜测?
// 不推荐硬编码猜测,因为版本太多。
// 最好返回 None,让调用者决定是报错还是让用户手动指定。
#[test]
fn test_add_repository_with_key_url() {
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
serde_yaml::Value::Mapping(
[
(
serde_yaml::Value::String("url".to_string()),
serde_yaml::Value::String(
"https://repo.example.com/ubuntu".to_string(),
),
),
(
serde_yaml::Value::String("distro".to_string()),
serde_yaml::Value::String("focal".to_string()),
),
(
serde_yaml::Value::String("key_url".to_string()),
serde_yaml::Value::String(
"https://repo.example.com/key.gpg".to_string(),
),
),
]
.into_iter()
.collect(),
),
]))
.unwrap();
None
let cmds = Apt.add_repository(&repo_yaml, None);
// First command: key download, last command: list file
assert!(cmds.len() >= 2);
let first_cmd = cmds[0].as_std();
let args: Vec<String> = first_cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let full_arg = args.join(" ");
assert!(full_arg.contains("curl"));
assert!(full_arg.contains("key.gpg"));
assert!(full_arg.contains("gpg --dearmor"));
}
#[test]
fn test_sanitize_repo_name() {
assert_eq!(sanitize_repo_name("https://example.com/repo"), "example.com_repo");
assert_eq!(sanitize_repo_name("http://mirror.tuna.tsinghua.edu.cn/ubuntu"), "mirror.tuna.tsinghua.edu.cn_ubuntu");
assert_eq!(sanitize_repo_name("ppa:user/name"), "user_name");
}
#[test]
fn test_extract_codename_from_codename_field() {
let info = Info::with_type(os_info::Type::Ubuntu);
assert!(extract_codename(Some(&info)).is_none());
}
#[test]
fn test_add_repository_invalid_yaml_returns_empty() {
let invalid_yaml = serde_yaml::Value::String("invalid".to_string());
let cmds = Apt.add_repository(&invalid_yaml, None);
assert!(cmds.is_empty());
}
#[test]
fn test_add_repository_missing_codename_returns_empty() {
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
serde_yaml::Value::Mapping(
[(
serde_yaml::Value::String("url".to_string()),
serde_yaml::Value::String(
"https://repo.example.com/ubuntu".to_string(),
),
)]
.into_iter()
.collect(),
),
]))
.unwrap();
let cmds = Apt.add_repository(&repo_yaml, None);
assert!(cmds.is_empty());
}
}
+275
View File
@@ -0,0 +1,275 @@
use crate::pm::PackageManager;
use os_info::Info;
use serde::Deserialize;
use serde_yaml::Value;
use tokio::process::Command;
#[derive(Clone)]
pub struct Dnf;
/// Repository configuration for dnf.
#[derive(Deserialize, Clone)]
struct DnfRepo {
/// Repository name (used as section header and filename).
name: String,
/// Base URL for packages.
baseurl: String,
/// Whether the repository is enabled (default: true).
#[serde(default = "default_enabled")]
enabled: bool,
/// GPG key URL.
#[serde(default)]
gpgkey: Option<String>,
/// Additional metadata (name, gpgcheck, etc.).
#[serde(default)]
description: Option<String>,
}
fn default_enabled() -> bool {
true
}
impl PackageManager for Dnf {
fn name(&self) -> &'static str {
"dnf"
}
fn binary(&self) -> &'static str {
"dnf"
}
fn update(&self) -> Vec<Command> {
let mut cmd = Command::new("dnf");
cmd.arg("makecache");
vec![cmd]
}
fn install(&self, package: &[String]) -> Vec<Command> {
let mut cmd = Command::new("dnf");
cmd.arg("install");
cmd.arg("-y");
cmd.args(package);
vec![cmd]
}
/// Changes the primary dnf mirror by replacing baseurl in the main repo file.
///
/// Uses sed to replace `baseurl=` lines in `/etc/yum.repos.d/` with the
/// user-specified mirror URL.
fn change_mirror(&self, repo: &str, _osinfo: Option<&Info>) -> Vec<Command> {
let script = format!(
"sed -i 's|^baseurl=[^ ].*|baseurl={}|' /etc/yum.repos.d/*.repo",
repo
);
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(script);
vec![cmd]
}
/// Adds a custom dnf repository by creating a .repo file in /etc/yum.repos.d/.
fn add_repository(&self, repo: &Value, _osinfo: Option<&Info>) -> Vec<Command> {
let repos: Vec<DnfRepo> = match serde_yaml::from_value(repo.clone()) {
Ok(r) => r,
Err(e) => {
log::error!("Failed to parse dnf repository: {}", e);
return Vec::new();
}
};
let mut cmds: Vec<Command> = vec![];
for repo_cfg in &repos {
// Build the .repo file content
let mut content = format!(
"[{}]\nname={}\nbaseurl={}\nenabled={}\n",
repo_cfg.name,
repo_cfg
.description
.clone()
.unwrap_or_else(|| repo_cfg.name.clone()),
repo_cfg.baseurl,
if repo_cfg.enabled { "1" } else { "0" },
);
if let Some(gpgkey) = &repo_cfg.gpgkey {
content.push_str(&format!("gpgcheck=1\ngpgkey={}\n", gpgkey));
// Download and import the GPG key
let mut key_cmd = Command::new("sh");
key_cmd.arg("-c").arg(format!(
"rpm --import '{}'",
gpgkey
));
cmds.push(key_cmd);
} else {
content.push_str("gpgcheck=0\n");
}
// Write the .repo file
let repo_path = format!("/etc/yum.repos.d/{}.repo", sanitize_repo_name(&repo_cfg.name));
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(format!(
"cat > '{}' << 'DNF_EOF'\n{}DNF_EOF",
repo_path, content
));
cmds.push(cmd);
}
cmds
}
}
/// Sanitizes a string to a safe filename component.
fn sanitize_repo_name(name: &str) -> String {
name.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "_")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_name() {
assert_eq!(Dnf.name(), "dnf");
}
#[test]
fn test_binary() {
assert_eq!(Dnf.binary(), "dnf");
}
#[test]
fn test_update_command_contents() {
let cmds = Dnf.update();
let cmd = cmds[0].as_std();
assert_eq!(cmd.get_program().to_string_lossy(), "dnf");
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(args, vec!["makecache"]);
}
#[test]
fn test_install_command_contents() {
let cmds = Dnf.install(&["curl".to_string()]);
let cmd = cmds[0].as_std();
assert_eq!(cmd.get_program().to_string_lossy(), "dnf");
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(args, vec!["install", "-y", "curl"]);
}
#[test]
fn test_install_multiple_packages() {
let cmds = Dnf.install(&["curl".to_string(), "git".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!["install", "-y", "curl", "git"]);
}
#[test]
fn test_change_mirror_command() {
let cmds = Dnf.change_mirror("http://mirror.tuna.tsinghua.edu.cn/fedora", 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("sed -i"));
assert!(full_arg.contains("baseurl="));
assert!(full_arg.contains("yum.repos.d"));
assert!(full_arg.contains("mirror.tuna.tsinghua.edu.cn"));
}
#[test]
fn test_add_repository_simple() {
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("custom-repo".to_string()),
),
(
serde_yaml::Value::String("baseurl".to_string()),
serde_yaml::Value::String(
"https://repo.example.com/fedora".to_string(),
),
),
]
.into_iter()
.collect(),
),
]))
.unwrap();
let cmds = Dnf.add_repository(&repo_yaml, None);
// Should have one command (write .repo file, no GPG key)
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 >"));
assert!(full_arg.contains("custom-repo.repo"));
assert!(full_arg.contains("repo.example.com"));
}
#[test]
fn test_add_repository_with_gpgkey() {
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("custom-repo".to_string()),
),
(
serde_yaml::Value::String("baseurl".to_string()),
serde_yaml::Value::String(
"https://repo.example.com/fedora".to_string(),
),
),
(
serde_yaml::Value::String("gpgkey".to_string()),
serde_yaml::Value::String(
"https://repo.example.com/gpg".to_string(),
),
),
]
.into_iter()
.collect(),
),
]))
.unwrap();
let cmds = Dnf.add_repository(&repo_yaml, None);
// Should have 2 commands: import GPG key, write .repo file
assert_eq!(cmds.len(), 2);
let key_cmd = cmds[0].as_std();
let args: Vec<String> = key_cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let full_arg = args.join(" ");
assert!(full_arg.contains("rpm --import"));
assert!(full_arg.contains("repo.example.com/gpg"));
}
#[test]
fn test_add_repository_invalid_yaml_returns_empty() {
let invalid_yaml = serde_yaml::Value::String("invalid".to_string());
let cmds = Dnf.add_repository(&invalid_yaml, None);
assert!(cmds.is_empty());
}
}
+1 -63
View File
@@ -1,5 +1,5 @@
use crate::pm::PackageManager;
use os_info::{Info, Type};
use os_info::Info;
use serde::Deserialize;
use serde_yaml::Value;
use tokio::process::Command;
@@ -77,20 +77,6 @@ impl PackageManager for Pacman {
"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"
@@ -212,54 +198,6 @@ mod tests {
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() {