feat(pm): add apk/dnf implementations and multi-distro test
infrastructure
This commit is contained in:
@@ -29,6 +29,7 @@
|
|||||||
- [测试策略](zh-CN/vol3_dev/testing_strategy.md)
|
- [测试策略](zh-CN/vol3_dev/testing_strategy.md)
|
||||||
- [插件系统指南](zh-CN/vol3_dev/plugin_system_guide.md)
|
- [插件系统指南](zh-CN/vol3_dev/plugin_system_guide.md)
|
||||||
- [Engine 模块指南](zh-CN/vol3_dev/engine_module_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/add_package_manager.md)
|
||||||
- [自定义插件](zh-CN/vol3_dev/custom_plugin.md)
|
- [自定义插件](zh-CN/vol3_dev/custom_plugin.md)
|
||||||
- [自定义 Decorator](zh-CN/vol3_dev/custom_decorator.md)
|
- [自定义 Decorator](zh-CN/vol3_dev/custom_decorator.md)
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
# 设计决策
|
||||||
|
|
||||||
|
本文档记录了 HoneyBiscuitWorkshop 架构演进过程中的关键设计决策及其背后的思考。
|
||||||
|
|
||||||
|
> **注意**:本文档面向开发者,记录的是**目标架构**的设计方向,而非当前代码的实现现状。
|
||||||
|
> 当前 bakerd 尚未完全实现(`daemon.rs` 为 `todo!()`),bare mode 仅用于开发调试。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Bare Mode 与 Daemon Mode
|
||||||
|
|
||||||
|
### 决策
|
||||||
|
|
||||||
|
**Bare mode 仅用于开发调试,不承载生产语义。**
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
|
||||||
|
当前代码提供两种运行入口:
|
||||||
|
|
||||||
|
| 模式 | 入口 | 状态 | 用途 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| **Bare mode** | `cargo run -- prebake/bake/finalize <args>` | 可用 | 开发调试 |
|
||||||
|
| **Daemon mode** | `cargo run --` (无子命令) | `todo!()` | 生产 |
|
||||||
|
|
||||||
|
### 推论
|
||||||
|
|
||||||
|
- Bare mode 中 `ctx.privileged` 是写死的 fake 值(`Prebake: true`, `Bake: false`),不代表生产行为
|
||||||
|
- Bare mode 中 `os_info::get()` 读取的是 HOST 的 OS 信息,不代表环境内的 OS
|
||||||
|
- 不要从 bare mode 的行为推导生产路径的问题
|
||||||
|
- 不要为了"修复" bare mode 的问题而给生产设计增加不必要的抽象
|
||||||
|
|
||||||
|
### 代码残留
|
||||||
|
|
||||||
|
当前 `bake.rs` 中存在以下已在设计层面淘汰、但因 bare mode 未被清理的代码:
|
||||||
|
|
||||||
|
- `fn privileged()` — 从未被调用
|
||||||
|
- `type TaskResult` — 从未被使用
|
||||||
|
- `type TaskFn` — 从未被使用
|
||||||
|
|
||||||
|
这些是 `bake.rs` 废弃功能的残留,不涉及生产设计。清理它们不改变任何行为。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 环境模型
|
||||||
|
|
||||||
|
### 决策
|
||||||
|
|
||||||
|
**bakerd 先创建环境(容器/VM/裸机),然后 baker 在环境内执行全部三个阶段(prebake → bake → finalize)。**
|
||||||
|
|
||||||
|
### 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
bakerd
|
||||||
|
├── prepare_environment (容器/VM/裸机) ← 先建隔离环境
|
||||||
|
├── prebake (全在环境内执行) ← 包括 depssystem 的包安装
|
||||||
|
├── bake (全在环境内执行) ← 构建脚本
|
||||||
|
├── finalize (全在环境内执行) ← 部署通知
|
||||||
|
└── cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
### 推论
|
||||||
|
|
||||||
|
- `prepare_environment` 不是 prebake 的一个 stage——它是 prebake 的前置条件
|
||||||
|
- `depssystem.rs` 中 `os_info::get()` 读取的应当是**环境内**的 `/etc/os-release`,逻辑正确
|
||||||
|
- 容器镜像只需拉取(`prepare_container`),不需要在代码层面管理容器生命周期——那是 bakerd 的职责
|
||||||
|
- 当前 `prepare_container` 返回的 `_container` 被丢弃,是因为 bakerd 尚未实现,不是设计缺陷
|
||||||
|
|
||||||
|
### 环境类型
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
env:
|
||||||
|
type: container # Docker/Podman 容器
|
||||||
|
# type: firecracker # Firecracker 微 VM
|
||||||
|
# type: baremetal # 裸机直接执行
|
||||||
|
image: ubuntu:24.04
|
||||||
|
```
|
||||||
|
|
||||||
|
环境声明决定了:
|
||||||
|
|
||||||
|
1. 隔离级别(进程级 / VM级 / 无隔离)
|
||||||
|
2. 操作系统和工具链(通过 `image`)
|
||||||
|
3. 包管理器(从 OS 推导)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. PM 选择链
|
||||||
|
|
||||||
|
### 决策
|
||||||
|
|
||||||
|
**包管理器(PM)应从环境声明中推导,而不是通过运行时探测。**
|
||||||
|
|
||||||
|
### 选择链
|
||||||
|
|
||||||
|
```
|
||||||
|
用户声明 bakerd 确定 执行时确定
|
||||||
|
env.image: ubuntu:24.04
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
容器运行时 (Docker/VM)
|
||||||
|
│ os_info::get() 在环境内
|
||||||
|
▼
|
||||||
|
OS: Ubuntu 24.04
|
||||||
|
│ 从已知映射推导
|
||||||
|
▼
|
||||||
|
PM: apt
|
||||||
|
│ depssystem 使用 PM trait 操作
|
||||||
|
▼
|
||||||
|
apt install -y {packages}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 为什么淘汰 detect()
|
||||||
|
|
||||||
|
`pm::detect()` 的当前逻辑:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn detect(distro: &os_info::Info) -> Option<Box<dyn PackageManager>> {
|
||||||
|
all_managers().into_iter().find(|pm| pm.adopt(distro))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
它在解决一个**已经知道答案的问题**:
|
||||||
|
|
||||||
|
- 用户声明了 `image: ubuntu:24.04`
|
||||||
|
- 容器内 `/etc/os-release` 确认了 Ubuntu
|
||||||
|
- `detect()` 遍历所有 PM 并匹配,得出 apt
|
||||||
|
- **结果和环境声明一致,但绕了一大圈**
|
||||||
|
|
||||||
|
`detect()` 的唯一价值场景:
|
||||||
|
|
||||||
|
- **Bare mode** 用户没配环境时,省一个配置项
|
||||||
|
- 这在生产路径中不应出现(bakerd 总是提供环境)
|
||||||
|
|
||||||
|
### 推论
|
||||||
|
|
||||||
|
- PM trait 保留(见下节),但 `adopt()` 方法在生产路径中不需要
|
||||||
|
- `detect()` / `adopt()` / `os_info::get()` 这条路径应当是 dev-only 的便利设施
|
||||||
|
- Daemon 模式中 PM 的选择依据是环境声明,不是运行时探测
|
||||||
|
- 当前 `os_info::Type` 的 58 个变体覆盖不全(仅 Pacman 可用)——这是 bare mode 的问题,不影响生产设计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. PM Trait 的职责边界
|
||||||
|
|
||||||
|
### 决策
|
||||||
|
|
||||||
|
**PM trait 保留核心操作能力,淘汰探测分类能力。**
|
||||||
|
|
||||||
|
### 核心方法
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait PackageManager {
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
fn update(&self) -> Vec<Command>;
|
||||||
|
fn install(&self, package: &[String]) -> Vec<Command>;
|
||||||
|
fn change_mirror(&self, repo: &str, osinfo: Option<&Info>) -> Vec<Command>;
|
||||||
|
fn add_repository(&self, repo: &Value, osinfo: Option<&Info>) -> Vec<Command>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这四个方法是 PM-specific 的操作,具有真实复杂度:
|
||||||
|
|
||||||
|
| 操作 | PM 间差异 | 原因 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `install` | 参数完全不同 | `apt install -y` vs `pacman -S --noconfirm` |
|
||||||
|
| `update` | 命令不同 | `apt update` vs `pacman -Sy` |
|
||||||
|
| `change_mirror` | 配置格式不同 | sources.list vs mirrorlist vs .repo |
|
||||||
|
| `add_repository` | 仓库机制不同 | PPA vs AUR vs COPR |
|
||||||
|
|
||||||
|
### 为什么镜像源和仓库在容器中仍然需要
|
||||||
|
|
||||||
|
容器不解决源管理问题:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker pull ubuntu:24.04
|
||||||
|
# 容器内 sources.list → archive.ubuntu.com
|
||||||
|
# 但用户在北京 → 需要 mirror.tuna.tsinghua.edu.cn
|
||||||
|
# 这不是预置在镜像中的——源是运行态配置
|
||||||
|
```
|
||||||
|
|
||||||
|
`change_mirror` 和 `add_repository` 都是**容器运行时需要**的操作,不能预置在基础镜像中。PM trait 的存在价值就在于这些操作。
|
||||||
|
|
||||||
|
### 淘汰的方法
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn adopt(&self, distro: &Info) -> bool; // 生产路径不需要
|
||||||
|
fn binary(&self) -> &'static str; // 从未被使用
|
||||||
|
```
|
||||||
|
|
||||||
|
### 实现策略
|
||||||
|
|
||||||
|
在 daemon 模式下,PM 的选型由环境声明确定(`image: ubuntu:24.04` → `apt`),PM 实现仅需按需注册,不需要自述适配范围:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 不再需要 detect() + adopt()
|
||||||
|
// PM 实例由工厂方法根据 PM 名称字符串创建
|
||||||
|
pub fn from_name(name: &str) -> Option<Box<dyn PackageManager>> {
|
||||||
|
match name {
|
||||||
|
"apt" => Some(Box::new(Apt)),
|
||||||
|
"pacman" => Some(Box::new(Pacman)),
|
||||||
|
"dnf" => Some(Box::new(Dnf)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 发行版多态
|
||||||
|
|
||||||
|
### 决策
|
||||||
|
|
||||||
|
**流水线不应是发行版多态的。发行版差异由容器化解决。**
|
||||||
|
|
||||||
|
### 原因
|
||||||
|
|
||||||
|
流水线的执行体(bake 脚本)天然发行版绑定:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# @pipeline
|
||||||
|
function build() {
|
||||||
|
cmake -B build -DCMAKE_INSTALL_PREFIX=/usr # Linux 路径
|
||||||
|
make -j$(nproc)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 换到 macOS:`/usr` 只读,路径不同
|
||||||
|
- 换到 Windows:MSVC 而不是 GCC
|
||||||
|
- 换到 Alpine:musl 而不是 glibc
|
||||||
|
|
||||||
|
这与 PM 的差异是同一类问题,但 PM 抽象不能解决它。**容器才是解决方案:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 在 Ubuntu 上构建
|
||||||
|
env:
|
||||||
|
type: container
|
||||||
|
image: ubuntu:24.04
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 在 Alpine 上构建
|
||||||
|
env:
|
||||||
|
type: container
|
||||||
|
image: alpine:3.19
|
||||||
|
```
|
||||||
|
|
||||||
|
### 各层面的发行版相关性
|
||||||
|
|
||||||
|
| 层面 | 发行版相关 | 解决方案 |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| DepsSystem(系统包) | ✅ | 容器内 PM |
|
||||||
|
| DepsUser(用户包管理器) | ❌ | 天然无关(cargo/npm/pip 跨发行版一致) |
|
||||||
|
| Bake(构建脚本) | ✅ | 容器内执行 |
|
||||||
|
| Finalize(后处理) | ❌ | 纯产物操作,无关发行版 |
|
||||||
|
|
||||||
|
### UPM 的特殊位置
|
||||||
|
|
||||||
|
用户包管理器(UPM,如 cargo、npm、pip、nix)天然跨发行版:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo install --locked bat # Ubuntu 和 Arch 上都一样
|
||||||
|
npm install -g typescript # Ubuntu 和 Arch 上都一样
|
||||||
|
```
|
||||||
|
|
||||||
|
`depsuser.rs` 处理的正是这个层面。但 `collect_upm_sysdeps()` 需要知道系统 PM——这是 UPM 和系统 PM 之间的 bridge。在设计上,这个 bridge 的 PM 信息应来自环境声明,而非 `detect()`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. drop_after 安全模型
|
||||||
|
|
||||||
|
### 决策
|
||||||
|
|
||||||
|
**`drop_after` 指定流水线在哪个 stage 之后从 root 降权到 vulcan 用户。合理的默认值是 `DepsUser`。**
|
||||||
|
|
||||||
|
### Stage 顺序
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum PrebakeStage {
|
||||||
|
Init,
|
||||||
|
Bootstrap,
|
||||||
|
EarlyHook,
|
||||||
|
DepsSystem, // ← 默认
|
||||||
|
DepsUser,
|
||||||
|
LateHook,
|
||||||
|
Ready,
|
||||||
|
Never, // ← 永远不降权
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 各 stage 的特权需求
|
||||||
|
|
||||||
|
| Stage | 需要 root | 原因 |
|
||||||
|
|-------|-----------|------|
|
||||||
|
| Bootstrap | ✅ | 创建系统用户(vulcan)、配置 sudo/doas |
|
||||||
|
| EarlyHook | ❓ | 用户自定义 |
|
||||||
|
| DepsSystem | ✅ | `apt install` 需要 root |
|
||||||
|
| DepsUser | ❌ | `cargo install` 不需要 root |
|
||||||
|
| LateHook | ❓ | 用户自定义 |
|
||||||
|
| Ready / Bake | ❌ | 构建应以非特权用户运行 |
|
||||||
|
|
||||||
|
### 正确的配置
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
security:
|
||||||
|
drop_after: "DepsUser"
|
||||||
|
```
|
||||||
|
|
||||||
|
执行效果:
|
||||||
|
|
||||||
|
```
|
||||||
|
Bootstrap root ← 创建用户
|
||||||
|
EarlyHook root ← hook 以 root 运行
|
||||||
|
DepsSystem root ← apt install -y {packages}
|
||||||
|
DepsUser root ← cargo install {packages}
|
||||||
|
LateHook vulcan ← 降权,用户脚本不能 rm -rf /
|
||||||
|
Ready vulcan
|
||||||
|
Bake vulcan ← 构建以 vulcan 运行
|
||||||
|
```
|
||||||
|
|
||||||
|
### 禁止的配置
|
||||||
|
|
||||||
|
`drop_after` 设置为 `Bootstrap` 或 `EarlyHook` 在当前 stage 顺序下会损坏 DepsStage(需要 root 权限的 `apt install` 在降权后无法执行)。
|
||||||
|
|
||||||
|
**配置验证应拒绝 `drop_after < DepsUser`**(以 root 运行的 user 级包安装没有意义,但 DepsSystem 必须 root)。
|
||||||
|
|
||||||
|
### 特殊值 Never
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
security:
|
||||||
|
drop_after: "Never" # 整条流水线以 root 运行
|
||||||
|
```
|
||||||
|
|
||||||
|
适用场景:Docker 构建、内核模块编译等需要 root 权限的操作。Bootstrap 的 `user: root` 配置也会产生相同效果。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 当前代码中的真问题
|
||||||
|
|
||||||
|
以下问题是当前代码中确实存在的,与 bare/daemon 模式无关:
|
||||||
|
|
||||||
|
### 7.1 Apt::adopt 永远返回 false
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// workshop-engine/src/pm/apt.rs:20-23
|
||||||
|
fn adopt(&self, distro: &Info) -> bool {
|
||||||
|
return false; // ← 永远 false
|
||||||
|
// todo!(); // ← 死代码
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `adopt()` 在 `return false` 后无法执行 `todo!()`
|
||||||
|
- Apt 永远不会被 `detect()` 选中
|
||||||
|
- 后果:Debian/Ubuntu 用户在 bare mode 下永远检测不到 PM
|
||||||
|
- 修复方向:移除 return false,实现真正的 adopt 逻辑(Debian/Ubuntu/Mint/Pop 等发行版的匹配)
|
||||||
|
|
||||||
|
### 7.2 PM 覆盖严重不全
|
||||||
|
|
||||||
|
当前 `all_managers()` 仅注册了 Pacman:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn all_managers() -> Vec<Box<dyn PackageManager>> {
|
||||||
|
vec![
|
||||||
|
// Box::new(apt::Apt), // adopt 坏了
|
||||||
|
Box::new(pacman::Pacman), // 唯一可用
|
||||||
|
// Box::new(dnf::Dnf), // 未实现
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
os_info 3.14.0 定义了 58 种发行版类型,Pacman 仅覆盖其中 9 种(Arch 系)。其余 49 种无 PM 覆盖。
|
||||||
|
|
||||||
|
### 7.3 死代码
|
||||||
|
|
||||||
|
| 位置 | 内容 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| `bake.rs:98` | `fn privileged()` | 定义但未调用 |
|
||||||
|
| `bake.rs:108` | `type TaskResult` | 定义但未使用 |
|
||||||
|
| `bake.rs:110` | `type TaskFn` | 定义但未使用 |
|
||||||
|
| `workshop-baker/src/prebake/stage/environment.rs` | `prepare_environment()` | 定义但未接入执行流 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Daemon 设计原则(汇总)
|
||||||
|
|
||||||
|
以上讨论最终可以提炼为以下设计原则,供 bakerd 重构时参考:
|
||||||
|
|
||||||
|
### P1. 环境声明驱动
|
||||||
|
|
||||||
|
```
|
||||||
|
用户声明 env.image → bakerd 创建环境 → 环境内确定 PM → depssystem 操作 PM
|
||||||
|
```
|
||||||
|
|
||||||
|
PM 的选择权在环境声明,不在运行时探测。`detect()` 不出现在生产路径中。
|
||||||
|
|
||||||
|
### P2. 容器是发行版隔离的边界
|
||||||
|
|
||||||
|
发行版差异在容器边界被隔断。容器内是已知、确定的环境。PM trait 只负责"已知 PM 的操作"(install、update、mirror、repo),不负责"猜是什么 PM"。
|
||||||
|
|
||||||
|
### P3. 降权边界可配置但需校验
|
||||||
|
|
||||||
|
`drop_after` 从 root 降权到 vulcan,默认在 DepsUser 之后。配置验证需确保:降权不会损坏需要 root 的 stage。
|
||||||
|
|
||||||
|
### P4. 最小化 trait 抽象
|
||||||
|
|
||||||
|
PM trait 保留操作系统所需的核心方法。不在 trait 中嵌入分类功能(adopt)。分类是一个数据映射问题,不是多态问题。
|
||||||
|
|
||||||
|
### P5. Bare mode 是开发工具,不承载生产行为
|
||||||
|
|
||||||
|
Bare mode 的行为(host 上直接执行、write static privileged 等)不代表生产设计。不要为了优化 bare mode 而增加生产路径的抽象负担。
|
||||||
|
|
||||||
|
### P6. 天然跨发行版的部分不需要抽象
|
||||||
|
|
||||||
|
UPM(cargo/npm/pip/nix)跨发行版一致,不需要 PM 级别的抽象,不需要 detect,不需要 distro 感知。trait 只应用于确实有 PM-specific 差异的部分。
|
||||||
@@ -1,112 +1,101 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# HoneyBiscuitWorkshop System Test Runner
|
# HoneyBiscuitWorkshop System Test Runner
|
||||||
# =============================================================================
|
|
||||||
# Usage: ./runner.sh [auto|manual] [-- pytest-args...]
|
|
||||||
#
|
#
|
||||||
# Modes:
|
# Usage:
|
||||||
# auto - Build, run all tests, collect reports, cleanup (default)
|
# ./runner.sh # Arch baker tests (hbw-system-test:latest)
|
||||||
# manual - Build, start container shell for interactive testing
|
# ./runner.sh --image hbw-test-pm:test-apt # Debian engine PM tests
|
||||||
#
|
# ./runner.sh -k test_install # Arch baker, filtered
|
||||||
# Examples:
|
|
||||||
# ./runner.sh # Run all tests automatically
|
|
||||||
# ./runner.sh auto -k test_prebake # Run only prebake tests
|
|
||||||
# ./runner.sh manual # Drop into container shell
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
MODE="${1:-auto}"
|
MODE="auto"
|
||||||
shift 2>/dev/null || true
|
TEST_IMAGE=""
|
||||||
PYTEST_ARGS="$@"
|
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)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
CONTAINER_NAME="hbw-system-test-container"
|
||||||
STAGING_DIR="/tmp/hbw-system-test"
|
STAGING_DIR="/tmp/hbw-system-test"
|
||||||
IMAGE_NAME="hbw-system-test:latest"
|
|
||||||
|
|
||||||
# ---- Cleanup handler ----
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
echo "[runner] Cleaning up..."
|
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||||
docker rm -f hbw-system-test-container 2>/dev/null || true
|
[ "$TEST_DIR" = "tests/baker" ] && rm -rf "$STAGING_DIR"
|
||||||
rm -rf "$STAGING_DIR"
|
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
# ---- Step 1: Build the binary ----
|
|
||||||
build_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"
|
cd "$SCRIPT_DIR/workshop-baker"
|
||||||
cargo build --release 2>&1
|
cargo build -p workshop-baker --target "$target" 2>&1 | tail -n 1
|
||||||
echo "[runner] Build complete."
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---- Step 2: Prepare staging directory ----
|
|
||||||
prepare_staging() {
|
prepare_staging() {
|
||||||
echo "[runner] Preparing staging directory..."
|
# Only needed for baker tests (hbw-system-test:latest mounts /workshop)
|
||||||
rm -rf "$STAGING_DIR"
|
if [ "$TEST_DIR" = "tests/baker" ]; then
|
||||||
mkdir -p "$STAGING_DIR"
|
mkdir -p "$STAGING_DIR"
|
||||||
|
cp "$SCRIPT_DIR/workshop-baker/target/x86_64-unknown-linux-musl/debug/workshop-baker" "$STAGING_DIR/"
|
||||||
# Copy binary
|
|
||||||
cp "$SCRIPT_DIR/workshop-baker/target/release/workshop-baker" "$STAGING_DIR/"
|
|
||||||
chmod +x "$STAGING_DIR/workshop-baker"
|
chmod +x "$STAGING_DIR/workshop-baker"
|
||||||
|
fi
|
||||||
# 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() {
|
run_container() {
|
||||||
local mode="$1"
|
local tag="${TEST_IMAGE##*:}"
|
||||||
echo "[runner] Starting container in $mode mode..."
|
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
|
||||||
|
|
||||||
mkdir -p "$SCRIPT_DIR/tests/results"
|
echo "[runner] Starting: $TEST_IMAGE (tests=$TEST_DIR${filter:+, filter=$filter})"
|
||||||
|
mkdir -p "$SCRIPT_DIR/tests/results"
|
||||||
|
|
||||||
if [ "$mode" = "manual" ]; then
|
local mounts="-v $SCRIPT_DIR:/src:ro"
|
||||||
docker run -it --rm \
|
local entrypoint=""
|
||||||
--name hbw-system-test-container \
|
|
||||||
--privileged \
|
if [ "$TEST_DIR" = "tests/baker" ]; then
|
||||||
-v "$STAGING_DIR:/workshop:ro" \
|
mounts="$mounts -v $STAGING_DIR:/workshop:ro"
|
||||||
-v "$SCRIPT_DIR/tests/results:/results" \
|
entrypoint='--entrypoint ""'
|
||||||
-e TEST_MODE=manual \
|
fi
|
||||||
"$IMAGE_NAME"
|
|
||||||
else
|
local cmd="sh -c \"python3 -m venv /tmp/v && . /tmp/v/bin/activate && \
|
||||||
docker run --rm \
|
pip install pytest pytest-timeout -q && \
|
||||||
--name hbw-system-test-container \
|
exec python -m pytest $TEST_DIR $PYTEST_ARGS $filter\""
|
||||||
--privileged \
|
|
||||||
-v "$STAGING_DIR:/workshop:ro" \
|
if [ "$MODE" = "manual" ]; then
|
||||||
-v "$SCRIPT_DIR/tests/results:/results" \
|
docker run -it --rm --name "$CONTAINER_NAME" --privileged $entrypoint \
|
||||||
-e TEST_MODE=auto \
|
$mounts "$TEST_IMAGE"
|
||||||
"$IMAGE_NAME" $PYTEST_ARGS
|
else
|
||||||
fi
|
docker run --rm --name "$CONTAINER_NAME" --privileged $entrypoint \
|
||||||
|
$mounts -w /src \
|
||||||
|
"$TEST_IMAGE" "$cmd"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---- Main flow ----
|
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo " HoneyBiscuitWorkshop System Test Runner"
|
echo " HBW System Test Runner"
|
||||||
echo " Mode: $MODE"
|
echo " Image: $TEST_IMAGE"
|
||||||
|
echo " Tests: $TEST_DIR"
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
|
|
||||||
build_binary
|
build_binary
|
||||||
prepare_staging
|
prepare_staging
|
||||||
build_image
|
run_container
|
||||||
run_container "$MODE"
|
|
||||||
|
|
||||||
echo "[runner] Done."
|
echo "[runner] Done."
|
||||||
|
|||||||
@@ -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)
|
||||||
Vendored
+6
@@ -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"]
|
||||||
Vendored
+6
@@ -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"]
|
||||||
Vendored
+6
@@ -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"]
|
||||||
Vendored
+2
@@ -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
|
||||||
@@ -2,18 +2,24 @@
|
|||||||
Environment probes for verifying system state in workshop-baker tests.
|
Environment probes for verifying system state in workshop-baker tests.
|
||||||
|
|
||||||
Each probe is a standalone function that can be called from any test.
|
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 .user import user_exists, user_uid, user_gid, user_in_group, user_shell
|
||||||
from .file import (
|
from .file import (
|
||||||
file_exists,
|
file_exists,
|
||||||
|
file_absent,
|
||||||
dir_exists,
|
dir_exists,
|
||||||
|
dir_absent,
|
||||||
file_permissions,
|
file_permissions,
|
||||||
file_contains,
|
file_contains,
|
||||||
file_owned_by,
|
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
|
from .process import process_running, process_count
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -23,12 +29,17 @@ __all__ = [
|
|||||||
"user_in_group",
|
"user_in_group",
|
||||||
"user_shell",
|
"user_shell",
|
||||||
"file_exists",
|
"file_exists",
|
||||||
|
"file_absent",
|
||||||
"dir_exists",
|
"dir_exists",
|
||||||
|
"dir_absent",
|
||||||
"file_permissions",
|
"file_permissions",
|
||||||
"file_contains",
|
"file_contains",
|
||||||
"file_owned_by",
|
"file_owned_by",
|
||||||
|
"file_group",
|
||||||
"package_installed",
|
"package_installed",
|
||||||
"package_version",
|
"package_version",
|
||||||
|
"binary_exists",
|
||||||
|
"binary_on_path",
|
||||||
"process_running",
|
"process_running",
|
||||||
"process_count",
|
"process_count",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -41,3 +41,13 @@ def file_group(path: str) -> str:
|
|||||||
"""Get the group owner of a file."""
|
"""Get the group owner of a file."""
|
||||||
st = os.stat(path)
|
st = os.stat(path)
|
||||||
return grp.getgrgid(st.st_gid).gr_name
|
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
@@ -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
|
import subprocess
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
Runner = Callable[..., Any]
|
||||||
|
|
||||||
|
|
||||||
def package_installed(package_name: str) -> bool:
|
def _run(cmd: list[str], runner: Runner = subprocess.run, **kw: Any) -> Any:
|
||||||
"""Check if a pacman package is installed."""
|
opts = {"capture_output": True, "text": True, "timeout": 30}
|
||||||
try:
|
opts.update(kw)
|
||||||
result = subprocess.run(
|
return runner(cmd, **opts)
|
||||||
["pacman", "-Q", package_name],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
return result.returncode == 0
|
|
||||||
except FileNotFoundError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def package_version(package_name: str) -> str | None:
|
def _succeeded(r: Any) -> bool:
|
||||||
"""Get installed version of a package, or ``None`` if not installed."""
|
""".returncode==0 for CompletedProcess, or ["success"] for dict."""
|
||||||
try:
|
if hasattr(r, "returncode"):
|
||||||
result = subprocess.run(
|
return r.returncode == 0
|
||||||
["pacman", "-Q", package_name],
|
return bool(r.get("success", False))
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
def binary_exists(path: str, runner: Runner = subprocess.run) -> bool:
|
||||||
if result.returncode == 0:
|
"""Check if a binary exists at *path* (e.g. ``/usr/bin/curl``).
|
||||||
# Output format: "package_name version"
|
|
||||||
parts = result.stdout.strip().split()
|
Uses ``test -f`` (POSIX, guaranteed available).
|
||||||
if len(parts) >= 2:
|
"""
|
||||||
return parts[1]
|
return _succeeded(_run(["test", "-f", path], runner))
|
||||||
return None
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
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
|
||||||
|
|||||||
+15
-11
@@ -13,15 +13,17 @@ def user_exists(username: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def user_uid(username: str) -> int:
|
def user_uid(username: str) -> int | None:
|
||||||
"""Get the UID of a user. Raises KeyError if user doesn't exist."""
|
try:
|
||||||
return pwd.getpwnam(username).pw_uid
|
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."""
|
|
||||||
return pwd.getpwnam(username).pw_gid
|
|
||||||
|
|
||||||
|
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:
|
def user_in_group(username: str, groupname: str) -> bool:
|
||||||
"""Check if a user is a member of a group."""
|
"""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
|
return False
|
||||||
|
|
||||||
|
|
||||||
def user_shell(username: str) -> str:
|
def user_shell(username: str) -> str | None:
|
||||||
"""Get the login shell of a user. Raises KeyError if user doesn't exist."""
|
try:
|
||||||
return pwd.getpwnam(username).pw_shell
|
return pwd.getpwnam(username).pw_shell
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|||||||
Generated
+92
-174
@@ -691,16 +691,6 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"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]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -1101,21 +1091,6 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
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]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -1215,8 +1190,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1226,9 +1203,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2285,17 +2264,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -2399,6 +2367,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2414,22 +2383,6 @@ dependencies = [
|
|||||||
"tower-service",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.19"
|
version = "0.1.19"
|
||||||
@@ -2449,11 +2402,9 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
"system-configuration",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-registry",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2806,18 +2757,18 @@ dependencies = [
|
|||||||
"fastrand",
|
"fastrand",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hostname",
|
|
||||||
"httpdate",
|
"httpdate",
|
||||||
"idna",
|
"idna",
|
||||||
"mime",
|
"mime",
|
||||||
"native-tls",
|
|
||||||
"nom",
|
"nom",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"quoted_printable",
|
"quoted_printable",
|
||||||
|
"rustls",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-rustls",
|
||||||
"url",
|
"url",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2865,6 +2816,12 @@ version = "0.4.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchit"
|
name = "matchit"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -2942,23 +2899,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.25.1"
|
version = "0.25.1"
|
||||||
@@ -3269,56 +3209,12 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
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]]
|
[[package]]
|
||||||
name = "ordered-multimap"
|
name = "ordered-multimap"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -3646,6 +3542,61 @@ dependencies = [
|
|||||||
"prost",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.42"
|
version = "1.0.42"
|
||||||
@@ -3791,29 +3742,26 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"h2",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
"hyper-tls",
|
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
|
||||||
"native-tls",
|
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-rustls",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -3821,6 +3769,7 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3871,6 +3820,12 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -3905,7 +3860,7 @@ version = "0.8.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3"
|
checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"openssl-probe 0.1.6",
|
"openssl-probe",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"schannel",
|
"schannel",
|
||||||
"security-framework",
|
"security-framework",
|
||||||
@@ -3917,6 +3872,7 @@ version = "1.13.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
|
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3998,7 +3954,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
|
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
@@ -4291,27 +4247,6 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "tar"
|
name = "tar"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@@ -4479,16 +4414,6 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@@ -4845,12 +4770,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "vcpkg"
|
|
||||||
version = "0.2.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -4959,6 +4878,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -5049,17 +4978,6 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ env_logger = "0.11.8"
|
|||||||
glob = "0.3.2"
|
glob = "0.3.2"
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
lazy_static = "1.5.0"
|
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"
|
log = "0.4.28"
|
||||||
minijinja = "2.14.0"
|
minijinja = "2.14.0"
|
||||||
regex = "1.12.2"
|
regex = "1.12.2"
|
||||||
@@ -47,7 +47,7 @@ uuid = { version = "1.19.0", features = ["v4"] }
|
|||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
os_info = "3.14.0"
|
os_info = "3.14.0"
|
||||||
privdrop = "0.5.6"
|
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"
|
which = "8.0.2"
|
||||||
gix = "0.81.0"
|
gix = "0.81.0"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ pub enum GitVersion {
|
|||||||
/// Format: `url` (no version), `url@tag` (single version), `url@tag:commit` (mixed version).
|
/// Format: `url` (no version), `url@tag` (single version), `url@tag:commit` (mixed version).
|
||||||
/// Returns (base_url, GitVersion).
|
/// Returns (base_url, GitVersion).
|
||||||
pub fn parse_git_url(url: &str) -> (String, 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(":") {
|
if version.contains(":") {
|
||||||
let (tag, commit) = version.split_once(':').unwrap();
|
let (tag, commit) = version.split_once(':').unwrap();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -154,8 +154,9 @@ pub async fn prebake(
|
|||||||
.map(|c| c.repology_endpoint)
|
.map(|c| c.repology_endpoint)
|
||||||
.unwrap_or(workshop_engine::RepologyEndpoint::Default);
|
.unwrap_or(workshop_engine::RepologyEndpoint::Default);
|
||||||
|
|
||||||
let osinfo = os_info::get();
|
let pm_name = dependencies.system
|
||||||
let detected_pm = workshop_engine::pm::detect(&osinfo).map(|pm| pm.name().to_string());
|
.as_ref()
|
||||||
|
.and_then(|sys| sys.keys().next().cloned());
|
||||||
|
|
||||||
let upm_sys_deps = if let Some(user) = &dependencies.user {
|
let upm_sys_deps = if let Some(user) = &dependencies.user {
|
||||||
if stage <= PrebakeStage::DepsSystem {
|
if stage <= PrebakeStage::DepsSystem {
|
||||||
@@ -170,7 +171,7 @@ pub async fn prebake(
|
|||||||
let mut system_config = dependencies.system.clone().unwrap_or_default();
|
let mut system_config = dependencies.system.clone().unwrap_or_default();
|
||||||
|
|
||||||
if !upm_sys_deps.deps.is_empty() {
|
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 {
|
for (upm_name, sys_deps) in &upm_sys_deps.deps {
|
||||||
let resolved = workshop_engine::repology::resolve_package_names(
|
let resolved = workshop_engine::repology::resolve_package_names(
|
||||||
&sys_deps.packages,
|
&sys_deps.packages,
|
||||||
|
|||||||
@@ -61,4 +61,7 @@ pub enum BootstrapError {
|
|||||||
|
|
||||||
#[error("Failed to create user {0}: {1}")]
|
#[error("Failed to create user {0}: {1}")]
|
||||||
UserCreationFailed(String, String),
|
UserCreationFailed(String, String),
|
||||||
}
|
|
||||||
|
#[error("Invalid bootstrap configuration: {0}")]
|
||||||
|
InvalidConfig(String),
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,21 +53,48 @@ pub async fn bootstrap(
|
|||||||
|
|
||||||
let bootstrap_config = config.bootstrap.clone().unwrap_or_default();
|
let bootstrap_config = config.bootstrap.clone().unwrap_or_default();
|
||||||
|
|
||||||
if let Some(custom_bootstrap) = bootstrap_config.custom {
|
if let Some(raw) = bootstrap_config.custom {
|
||||||
if ctx.dry_run {
|
match BootstrapSource::parse(&raw) {
|
||||||
log::info!(
|
BootstrapSource::Inline(script) => {
|
||||||
"[DRY_RUN] User provided bootstrap script:\n{}",
|
if ctx.dry_run {
|
||||||
custom_bootstrap
|
log::info!("[DRY_RUN] User provided inline bootstrap script:\n{}", script);
|
||||||
);
|
return Ok(());
|
||||||
return Ok(());
|
}
|
||||||
}
|
log::debug!("Using inline bootstrap script ({} bytes)", script.len());
|
||||||
// TODO: Parse custom bootstrap URL and download them
|
if ctx.standalone {
|
||||||
log::debug!("User provided bootstrap script:\n{}", custom_bootstrap);
|
write_bootstrap_content(&bootstrap_path, script)?;
|
||||||
if ctx.standalone {
|
} else {
|
||||||
write_bootstrap_content(&bootstrap_path, custom_bootstrap)?;
|
// Requires PIP(Pipeline Implicit Parameters) to be implemented
|
||||||
} else {
|
unimplemented!("Permission check is not implemented, failing...");
|
||||||
// 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 {
|
} else {
|
||||||
let script = generate_bootstrap(&bootstrap_config, config, osinfo, ctx.standalone)?;
|
let script = generate_bootstrap(&bootstrap_config, config, osinfo, ctx.standalone)?;
|
||||||
@@ -102,6 +129,52 @@ fn write_bootstrap_content(bootstrap_path: &Path, content: String) -> Result<(),
|
|||||||
Ok(())
|
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.
|
/// Generates a bootstrap script based on configuration and target OS.
|
||||||
///
|
///
|
||||||
/// The generated script performs the following setup tasks:
|
/// 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)
|
/// * `bootstrap_config` - Bootstrap-specific configuration (user, workspace, sudoers)
|
||||||
/// * `config` - Full prebake configuration for dependency and package manager detection
|
/// * `config` - Full prebake configuration for dependency and package manager detection
|
||||||
/// * `osinfo` - Operating system information for package manager detection
|
/// * `_osinfo` - Operating system information (reserved)
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
@@ -134,7 +207,7 @@ fn write_bootstrap_content(bootstrap_path: &Path, content: String) -> Result<(),
|
|||||||
fn generate_bootstrap(
|
fn generate_bootstrap(
|
||||||
bootstrap_config: &Bootstrap,
|
bootstrap_config: &Bootstrap,
|
||||||
config: &PrebakeConfig,
|
config: &PrebakeConfig,
|
||||||
osinfo: &os_info::Info,
|
_osinfo: &os_info::Info,
|
||||||
standalone: bool,
|
standalone: bool,
|
||||||
) -> Result<String, BootstrapError> {
|
) -> Result<String, BootstrapError> {
|
||||||
let mut script = String::new();
|
let mut script = String::new();
|
||||||
@@ -190,7 +263,7 @@ fn generate_bootstrap(
|
|||||||
|
|
||||||
// Setup privilege executor
|
// Setup privilege executor
|
||||||
script.push_str("# Setup privilege executor\n");
|
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 which("sudo").is_ok() {
|
||||||
if let Some(sudoers) = &bootstrap_config.sudoers {
|
if let Some(sudoers) = &bootstrap_config.sudoers {
|
||||||
if standalone {
|
if standalone {
|
||||||
@@ -242,60 +315,32 @@ fn generate_bootstrap(
|
|||||||
Ok(script)
|
Ok(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detects and selects an appropriate package manager based on configuration and OS.
|
/// Selects the package manager from the first configured system dependency.
|
||||||
///
|
|
||||||
/// This function first attempts to detect the OS's native package manager, then
|
|
||||||
/// checks if it's configured in the prebake.yml. If the detected package manager
|
|
||||||
/// is not configured, it falls back to the first configured package manager.
|
|
||||||
///
|
|
||||||
/// # Detection Priority
|
|
||||||
///
|
|
||||||
/// 1. **Detected Package Manager**: Uses `pm::detect()` to find the OS-native package manager
|
|
||||||
/// (e.g., `apt` on Debian/Ubuntu, `pacman` on Arch Linux)
|
|
||||||
/// 2. **Configured Package Manager**: If detected manager is not in config, uses the
|
|
||||||
/// first configured package manager from the dependencies section
|
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
/// * `config` - Prebake configuration containing dependencies.system configuration
|
/// * `config` - Prebake configuration containing dependencies.system configuration
|
||||||
/// * `osinfo` - Operating system information for package manager detection
|
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Returns `Some(Box<dyn PackageManager>)` if a suitable package manager is found,
|
/// Returns `Some(Box<dyn PackageManager>)` if a configured package manager is known,
|
||||||
/// or `None` if no package manager is configured or detectable.
|
/// or `None` if no package manager is configured or recognized.
|
||||||
fn get_package_manager(
|
fn get_package_manager(
|
||||||
config: &PrebakeConfig,
|
config: &PrebakeConfig,
|
||||||
osinfo: &os_info::Info,
|
|
||||||
) -> Option<Box<dyn pm::PackageManager>> {
|
) -> Option<Box<dyn pm::PackageManager>> {
|
||||||
config
|
config
|
||||||
.dependencies
|
.dependencies
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|deps| deps.system.as_ref())
|
.and_then(|deps| deps.system.as_ref())
|
||||||
.and_then(|sysdeps| {
|
.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()
|
if let Some((name, _)) = sysdeps.iter().next()
|
||||||
&& let Some(pm) = pm::select(name)
|
&& let Some(pm) = pm::select(name)
|
||||||
{
|
{
|
||||||
log::warn!(
|
log::info!("Using package manager: {}", name);
|
||||||
"Using package manager: {} (first configured, not detected)",
|
|
||||||
name
|
|
||||||
);
|
|
||||||
return Some(pm);
|
return Some(pm);
|
||||||
}
|
}
|
||||||
|
|
||||||
log::warn!("No valid package manager found in configuration");
|
log::warn!("No known package manager found in dependencies.system");
|
||||||
None
|
None
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use workshop_engine::{Engine, EventSender, error::DependencyError, pm};
|
|||||||
/// Handles system dependency installation for the target platform.
|
/// Handles system dependency installation for the target platform.
|
||||||
///
|
///
|
||||||
/// This function:
|
/// This function:
|
||||||
/// - Detects the operating system and package manager
|
/// - Selects the package manager from the configured dependencies
|
||||||
/// - Optionally changes the package manager mirror
|
/// - Optionally changes the package manager mirror
|
||||||
/// - Optionally adds custom repositories
|
/// - Optionally adds custom repositories
|
||||||
/// - Updates the package index
|
/// - Updates the package index
|
||||||
@@ -19,21 +19,21 @@ pub async fn depssystem(
|
|||||||
let osinfo = os_info::get();
|
let osinfo = os_info::get();
|
||||||
let engine = Engine::new();
|
let engine = Engine::new();
|
||||||
|
|
||||||
// Detect package manager
|
// Select package manager from the first configured system dependency
|
||||||
let package_manager = pm::detect(&osinfo).ok_or_else(|| {
|
let package_manager = config
|
||||||
log::error!("No supported package manager found for {}", &osinfo);
|
.keys()
|
||||||
DependencyError::UnsupportedPlatform(osinfo.clone())
|
.find_map(|name| pm::select(name))
|
||||||
})?;
|
.ok_or_else(|| {
|
||||||
log::info!("Detected package manager: {}", package_manager.name());
|
log::error!(
|
||||||
|
"No known package manager configured in dependencies.system for {}",
|
||||||
|
&osinfo
|
||||||
|
);
|
||||||
|
DependencyError::UnsupportedPlatform(osinfo.clone())
|
||||||
|
})?;
|
||||||
|
log::info!("Using package manager: {}", package_manager.name());
|
||||||
|
|
||||||
// Retrieve configuration
|
// Retrieve configuration
|
||||||
let Some(pm_config) = config.get(package_manager.name()) else {
|
let pm_config = &config[package_manager.name()];
|
||||||
log::info!(
|
|
||||||
"No configuration found for {}, skipping system dependencies",
|
|
||||||
package_manager.name()
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
// Change mirror
|
// Change mirror
|
||||||
if let Some(mirror) = &pm_config.mirror {
|
if let Some(mirror) = &pm_config.mirror {
|
||||||
|
|||||||
@@ -88,13 +88,13 @@ impl StageInfo {
|
|||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
/// ```
|
/// ```
|
||||||
/// # use workshop_baker::types::buildstatus::{StageInfo, StagePhase, StageResult};
|
/// # use workshop_baker::types::buildstatus::{StageInfo, StagePhase, StageOutcome};
|
||||||
/// # use chrono::Utc;
|
/// # use chrono::Utc;
|
||||||
/// let info = StageInfo {
|
/// let info = StageInfo {
|
||||||
/// name: "bootstrap".to_string(),
|
/// name: "bootstrap".to_string(),
|
||||||
/// phase: StagePhase::Prebake,
|
/// phase: StagePhase::Prebake,
|
||||||
/// substage: "bootstrap".to_string(),
|
/// substage: "bootstrap".to_string(),
|
||||||
/// result: StageResult::Success,
|
/// result: StageOutcome::Success,
|
||||||
/// duration_ms: 1000,
|
/// duration_ms: 1000,
|
||||||
/// started_at: Utc::now(),
|
/// started_at: Utc::now(),
|
||||||
/// finished_at: Utc::now(),
|
/// finished_at: Utc::now(),
|
||||||
|
|||||||
+52
-21
@@ -1,8 +1,9 @@
|
|||||||
use os_info::Info;
|
use os_info::Info;
|
||||||
use serde_yaml::Value;
|
use serde_yaml::Value;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
// TODO: apt
|
mod apk;
|
||||||
// mod apt;
|
mod apt;
|
||||||
|
mod dnf;
|
||||||
mod pacman;
|
mod pacman;
|
||||||
|
|
||||||
/// Trait for package manager implementations.
|
/// Trait for package manager implementations.
|
||||||
@@ -13,8 +14,6 @@ pub trait PackageManager {
|
|||||||
/// Returns the name of the package manager.
|
/// Returns the name of the package manager.
|
||||||
fn name(&self) -> &'static str;
|
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.
|
/// Returns the binary name of the package manager.
|
||||||
fn binary(&self) -> &'static str;
|
fn binary(&self) -> &'static str;
|
||||||
@@ -25,12 +24,6 @@ pub trait PackageManager {
|
|||||||
/// Generates commands to install the specified packages.
|
/// Generates commands to install the specified packages.
|
||||||
fn install(&self, package: &[String]) -> Vec<Command>;
|
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.
|
/// Generates commands to change the primary mirror repository.
|
||||||
fn change_mirror(&self, repo: &str, osinfo: Option<&Info>) -> Vec<Command>;
|
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.
|
/// Returns a list of all available package managers.
|
||||||
pub fn all_managers() -> Vec<Box<dyn PackageManager>> {
|
pub fn all_managers() -> Vec<Box<dyn PackageManager>> {
|
||||||
vec![
|
vec![
|
||||||
// Box::new(apt::Apt),
|
Box::new(apk::Apk),
|
||||||
|
Box::new(apt::Apt),
|
||||||
|
Box::new(dnf::Dnf),
|
||||||
Box::new(pacman::Pacman),
|
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>> {
|
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)
|
all_managers().into_iter().find(|pm| pm.name() == manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,15 +58,60 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_select_known_manager() {
|
fn test_select_pacman() {
|
||||||
let pm = select("pacman");
|
let pm = select("pacman");
|
||||||
assert!(pm.is_some());
|
assert!(pm.is_some());
|
||||||
assert_eq!(pm.unwrap().name(), "pacman");
|
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]
|
#[test]
|
||||||
fn test_select_unknown_manager() {
|
fn test_select_unknown_manager() {
|
||||||
let pm = select("nonexistent_pm");
|
let pm = select("nonexistent_pm");
|
||||||
assert!(pm.is_none());
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,15 +1,48 @@
|
|||||||
use crate::pm::PackageManager;
|
use crate::pm::PackageManager;
|
||||||
use os_info::{Info, Version};
|
use os_info::{Info, Version};
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_yaml::Value;
|
use serde_yaml::Value;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Apt;
|
pub struct Apt;
|
||||||
|
|
||||||
#[derive(Clone)]
|
/// Repository configuration for apt.
|
||||||
struct AptRepositories {
|
#[derive(Deserialize, Clone)]
|
||||||
|
struct AptRepo {
|
||||||
|
/// The deb line URL (e.g., `https://repo.example.com/ubuntu`)
|
||||||
url: String,
|
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 {
|
impl PackageManager for Apt {
|
||||||
@@ -17,49 +50,200 @@ impl PackageManager for Apt {
|
|||||||
"apt"
|
"apt"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn adopt(&self, distro: &Info) -> bool {
|
|
||||||
return false;
|
|
||||||
// todo!();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn binary(&self) -> &'static str {
|
fn binary(&self) -> &'static str {
|
||||||
"apt"
|
"apt-get"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&self) -> Command {
|
fn update(&self) -> Vec<Command> {
|
||||||
let mut cmd = Command::new("apt");
|
let mut cmd = Command::new("apt-get");
|
||||||
cmd.arg("update");
|
cmd.arg("update");
|
||||||
cmd
|
vec![cmd]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install(&self, package: &[String]) -> Command {
|
fn install(&self, package: &[String]) -> Vec<Command> {
|
||||||
let mut cmd = Command::new("apt");
|
let mut cmd = Command::new("apt-get");
|
||||||
cmd.arg("install");
|
cmd.arg("install");
|
||||||
cmd.arg("-y"); // 自动确认
|
cmd.arg("-y");
|
||||||
cmd.args(package);
|
cmd.args(package);
|
||||||
cmd
|
vec![cmd]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change_mirror(&self, repo: &str, osinfo: Option<&Info>) -> Command {
|
/// Changes the primary apt mirror by replacing the URL in /etc/apt/sources.list.
|
||||||
|
///
|
||||||
todo!();
|
/// 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> {
|
fn add_repository(&self, repo: &Value, osinfo: Option<&Info>) -> Vec<Command> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
todo!();
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_name() {
|
||||||
|
assert_eq!(Apt.name(), "apt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_binary() {
|
||||||
|
assert_eq!(Apt.binary(), "apt-get");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_update_command_contents() {
|
fn test_update_command_contents() {
|
||||||
let cmd = Apt.update();
|
let cmds = Apt.update();
|
||||||
let cmd = cmd.as_std();
|
let cmd = cmds[0].as_std();
|
||||||
assert_eq!(cmd.get_program().to_string_lossy(), "apt");
|
assert_eq!(cmd.get_program().to_string_lossy(), "apt-get");
|
||||||
let args: Vec<String> = cmd
|
let args: Vec<String> = cmd
|
||||||
.get_args()
|
.get_args()
|
||||||
.map(|a| a.to_string_lossy().into_owned())
|
.map(|a| a.to_string_lossy().into_owned())
|
||||||
@@ -69,47 +253,165 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_install_command_contents() {
|
fn test_install_command_contents() {
|
||||||
let cmd = Apt.install(&["curl".to_string()]);
|
let cmds = Apt.install(&["curl".to_string()]);
|
||||||
let cmd = cmd.as_std();
|
let cmd = cmds[0].as_std();
|
||||||
assert_eq!(cmd.get_program().to_string_lossy(), "apt");
|
assert_eq!(cmd.get_program().to_string_lossy(), "apt-get");
|
||||||
let args: Vec<String> = cmd
|
let args: Vec<String> = cmd
|
||||||
.get_args()
|
.get_args()
|
||||||
.map(|a| a.to_string_lossy().into_owned())
|
.map(|a| a.to_string_lossy().into_owned())
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(args, vec!["install", "-y", "curl"]);
|
assert_eq!(args, vec!["install", "-y", "curl"]);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// 辅助函数:从 os_info 中提取 Codename
|
#[test]
|
||||||
fn extract_codename(info: Option<&Info>) -> Option<String> {
|
fn test_install_multiple_packages() {
|
||||||
let info = info?;
|
let cmds = Apt.install(&["curl".to_string(), "git".to_string()]);
|
||||||
|
let cmd = cmds[0].as_std();
|
||||||
// 1. 直接获取 codename (os_info 较新版本支持)
|
let args: Vec<String> = cmd
|
||||||
if let Some(codename) = info.codename() {
|
.get_args()
|
||||||
if !codename.is_empty() {
|
.map(|a| a.to_string_lossy().into_owned())
|
||||||
return Some(codename.to_string());
|
.collect();
|
||||||
}
|
assert_eq!(args, vec!["install", "-y", "curl", "git"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 尝试从 version 中推断 (针对某些把 codename 放在 Custom 里的情况)
|
#[test]
|
||||||
match info.version() {
|
fn test_change_mirror_command() {
|
||||||
Version::Custom(v) => {
|
let cmds = Apt.change_mirror("http://mirror.tuna.tsinghua.edu.cn/ubuntu", None);
|
||||||
// 有些系统可能把 codename 放在这里,虽然不标准
|
let cmd = cmds[0].as_std();
|
||||||
if !v.chars().next().map(|c| c.is_numeric()).unwrap_or(true) {
|
assert_eq!(cmd.get_program().to_string_lossy(), "sh");
|
||||||
return Some(v.clone());
|
let args: Vec<String> = cmd
|
||||||
}
|
.get_args()
|
||||||
}
|
.map(|a| a.to_string_lossy().into_owned())
|
||||||
Version::Semantic(major, minor, _) => {
|
.collect();
|
||||||
// 对于 Ubuntu/Debian,如果没有 codename,有时可以用版本号兜底 (如 22.04)
|
let full_arg = args.join(" ");
|
||||||
// 但 apt 源通常更喜欢 codename。如果实在没有,返回版本号字符串作为最后手段
|
assert!(full_arg.contains("sed -i"));
|
||||||
return Some(format!("{}.{}", major, minor));
|
assert!(full_arg.contains("/etc/apt/sources.list"));
|
||||||
}
|
assert!(full_arg.contains("mirror.tuna.tsinghua.edu.cn"));
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 如果 os_info 没提供,且是常见发行版,可以尝试根据 Type 猜测?
|
#[test]
|
||||||
// 不推荐硬编码猜测,因为版本太多。
|
fn test_add_repository_simple_deb_line() {
|
||||||
// 最好返回 None,让调用者决定是报错还是让用户手动指定。
|
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();
|
||||||
|
|
||||||
None
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
use crate::pm::PackageManager;
|
use crate::pm::PackageManager;
|
||||||
use os_info::{Info, Type};
|
use os_info::Info;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_yaml::Value;
|
use serde_yaml::Value;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
@@ -77,20 +77,6 @@ impl PackageManager for Pacman {
|
|||||||
"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 {
|
fn binary(&self) -> &'static str {
|
||||||
"pacman"
|
"pacman"
|
||||||
@@ -212,54 +198,6 @@ mod tests {
|
|||||||
assert_eq!(pacman.binary(), "pacman");
|
assert_eq!(pacman.binary(), "pacman");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_adopt_true_for_arch_based_distros() {
|
|
||||||
let pacman = Pacman;
|
|
||||||
let arch_distros = [
|
|
||||||
Type::Arch,
|
|
||||||
Type::Manjaro,
|
|
||||||
Type::EndeavourOS,
|
|
||||||
Type::Garuda,
|
|
||||||
Type::CachyOS,
|
|
||||||
Type::Artix,
|
|
||||||
Type::Mabox,
|
|
||||||
Type::Nobara,
|
|
||||||
Type::InstantOS,
|
|
||||||
];
|
|
||||||
for distro in arch_distros {
|
|
||||||
let info = Info::with_type(distro);
|
|
||||||
assert!(
|
|
||||||
pacman.adopt(&info),
|
|
||||||
"Pacman should adopt distro: {:?}",
|
|
||||||
distro
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_adopt_false_for_non_arch_distros() {
|
|
||||||
let pacman = Pacman;
|
|
||||||
let non_arch_distros = [
|
|
||||||
Type::Ubuntu,
|
|
||||||
Type::Debian,
|
|
||||||
Type::Fedora,
|
|
||||||
Type::CentOS,
|
|
||||||
Type::RedHatEnterprise,
|
|
||||||
Type::openSUSE,
|
|
||||||
Type::Gentoo,
|
|
||||||
Type::FreeBSD,
|
|
||||||
Type::Macos,
|
|
||||||
Type::Windows,
|
|
||||||
];
|
|
||||||
for distro in non_arch_distros {
|
|
||||||
let info = Info::with_type(distro);
|
|
||||||
assert!(
|
|
||||||
!pacman.adopt(&info),
|
|
||||||
"Pacman should NOT adopt distro: {:?}",
|
|
||||||
distro
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_update_command_contents() {
|
fn test_update_command_contents() {
|
||||||
|
|||||||
Reference in New Issue
Block a user