crates\nfarms-core\src\observability.rs¶
源码路径:
crates\nfarms-core\src\observability.rs· 2295 行
//! 可观测性三件套 —— VictoriaMetrics(指标)/ Loki(日志)/ Grafana(看板)。
//!
//! 三个都是**外部 exe**,和 bililive-go 一样由 `fetch-libs.ps1` 放进 `libs\`,
//! 程序不打包它们。本模块只做六件事,不重写它们:
//!
//! 1. 找到它们、拼出启动命令行([`spec`] / [`missing`] / [`start`])
//! 2. 等它们真的能服务([`wait_ready`])
//! 3. 往指标库/日志库喂数据([`push_metric`] / [`push_log`])
//! 4. 生成 Grafana 的数据源与看板([`provision_datasources`] / [`provision_dashboards`])
//! 5. 缺配置时补上([`ensure_configs`])
//! 6. 把应用 logo 装进 Grafana 并让 `[branding]` 指向它([`install_branding`])
//!
//! ## 端口、exe 名、就绪耗时全是**实测值**
//!
//! | 服务 | exe(`libs\` 下) | 端口 | 健康检查 | 实测就绪 |
//! |---|---|---|---|---|
//! | VictoriaMetrics | `victoria-metrics-windows-amd64-prod.exe` | 9091 | `/health` | 1 秒 |
//! | Loki | `loki-windows-amd64.exe` | 9092 | `/ready` | **17 秒** |
//! | Grafana | `grafana\bin\grafana.exe` | 9093 | `/api/health` | 7 秒 |
//!
//! Loki 那 17 秒是这个模块里最要紧的一个数:就绪预算按 [`Spec::ready_hint_secs`]
//! 算而不是拍一个「5 秒应该够了吧」,否则 loki 每次都被判成起不来,而它其实正在
//! 正常启动(见 `ready_budget`)。
//!
//! ## 运行产物放 `data\`,不放 `libs\`
//!
//! `libs\` 是**第三方组件**的地盘,`data\` 才是运行产物:
//! `data\vm\`(TSDB)、`data\loki\loki-config.yaml`、`data\grafana\custom.ini`、
//! `data\grafana\provisioning\datasources\nfarms.yml`、
//! `data\grafana\provisioning\dashboards\nfarms.yml` 和 `data\grafana\dashboards\nfarms.json`。
//! 分开的理由很实际 ——
//! 升级/重装三件套时整个 `libs\` 可以被覆盖掉,而 `data\` 里的库和配置要留着。
//!
//! **唯一的例外是 [`install_branding`]**:品牌 logo 必须落在 Grafana 自己的
//! `public\` 里(它只从 homepath 提供静态文件),所以那一步会往 `libs\grafana\`
//! 写一个文件。理由和代价都写在那条函数的注释里 —— 总之**重装 libs 之后要重新
//! install 一次**。
//!
//! ## 三条踩过的坑
//!
//! * **`CREATE_NO_WINDOW`**:三件套都是控制台程序,从 GUI 里 `spawn` 不加这个
//! 会先闪一个黑框(先例:`chat.rs::draw` / `wireshark.rs`)。
//! * **`timeout_global` 必须显式设**:ureq 默认 `None` = 永不超时,对端「连上了
//! 但不回话」时调用方永久卡死(先例:`weather.rs` / `chat.rs` 都为此修过)。
//! * **`.tmp` + `rename` 落盘**:直接 `fs::write` 先截断原文件,写到一半崩了
//! 就只剩半份配置(先例:`settings.rs::save` / `store.rs::save`)。
//!
//! ## 进程回收:Job Object(实测漏过 91 个孤儿进程)
//!
//! Grafana 13 起的时候会派生**一堆插件后端子进程**(实测 13 个
//! `gpx_*_windows_amd64.exe`,每个约 30 MB),而 `Child::kill()` 只打**直接**
//! 子进程 —— 那 13 个会变成孤儿,启动器关掉之后还在跑。实测在机器上累计查到
//! **91 个**(来自 6 轮启动,父进程全部已消失)。
//!
//! 所以 [`start`] 起出来的子进程会挂进 Job Object
//! (`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`),用的是 `live.rs`(bililive-go)
//! 那份实现 —— `crate::job_win`,**不另抄一份**(同一段 `unsafe` 有两份就是
//! 两个会各自漂移的来源;那边连"结构体 136≠144 只回一个参数错"这个坑都踩过了)。
//!
//! * 三件服务**各有一个 Job**,不是三件共用一个:关句柄 = 结束 Job 里**所有**
//! 进程,共用的话「停止 Grafana」会把 Loki / VictoriaMetrics 一起带走。
//! * Job 成员资格默认被子进程继承,所以 Grafana 派生出来的插件后端也在里面 ——
//! 关句柄收走的是**整棵树**。
//! * 句柄存在本模块的进程级槽里(见 `JOBS`),于是它和**启动器进程同寿命**:
//! 启动器正常退出、崩溃、被任务管理器结束,内核都会关掉句柄,整棵树跟着走。
//!
//! **界面层(`nfarms-gui/src/lib.rs`)只需要多一行**,写在 `obs_stop` 里:
//!
//! ```ignore
//! // 停之前先关这一件的 Job:内核连带结束它派生的所有子孙进程
//! observability::close_job(OBS_SERVICES[idx]);
//! let _ = child.kill(); // 原来打头的是这一行;关掉 Job 之后它基本是空操作
//! ```
//!
//! 挂 Job 失败(比如启动器自己已经在某个**不允许嵌套**的 Job 里,CI 上很常见)
//! **不挡服务启动**:退回"不回收"的旧行为,并把原因留在 [`take_job_warning`]
//! 里 —— "这一件将来没人回收"正是需要留下痕迹的那类事。
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
#[cfg(windows)]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
// Job Object 的封装在 `live.rs` 那边就存在了 —— 这里只是**用**它,见模块头。
#[cfg(windows)]
use crate::job_win;
// ---------------------------------------------------------------- 固定的量
/// `CREATE_NO_WINDOW`(0x0800_0000):子进程不弹控制台窗口。
///
/// 只有 Windows 有这个东西。非 Windows(工作区里的 `nfarms-mobile` 要编
/// Android)上它**不参与编译**,而不是留着当一个「看起来设了其实没设」的空操作。
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// 指标库(VictoriaMetrics)默认端口 —— 实测值。
const METRICS_PORT: u16 = 9091;
/// 日志库(Loki)默认端口。
const LOGS_PORT: u16 = 9092;
/// 看板(Grafana)默认端口。
const DASHBOARD_PORT: u16 = 9093;
/// 单次健康检查请求的上限。
///
/// 和「总共等多久」是两件事:这个管**一次** HTTP 往返,那个管整个就绪窗口。
/// 取 2 秒是为了让「端口连得上但一直不回话」这种半死状态尽快轮空一次、回到
/// 轮询节奏 —— 不设的话一次连接就能把整个就绪预算吃掉,然后报「超时」,
/// 而真实原因是一次莫名其妙的挂起(这就是 `weather.rs` 那次的形态)。
const HEALTH_REQUEST_TIMEOUT_SECS: u64 = 2;
/// 健康检查的轮询间隔。
///
/// 250 毫秒:VM 实测 1 秒就绪,粗了会让「1 秒的服务」白等半秒;细了只是空转
/// 回环请求。**不做指数退避** —— 三件套的就绪窗口最长也就十几秒,退避省下的
/// 那点请求量换不来「明明好了却还在等」的响应延迟。
const POLL_INTERVAL: Duration = Duration::from_millis(250);
/// 推数据(指标/日志)的 HTTP 上限。
///
/// 这两个都是本机回环、且只在服务已经就绪后才调,10 秒足够;但**必须显式设**:
/// ureq 的 `timeout_global` 默认 `None`,等于永不超时,对端一挂调用方就永久卡住。
const PUSH_TIMEOUT_SECS: u64 = 10;
/// 三个服务,顺序和界面上的一致(指标 → 日志 → 看板 = 数据的流向)。
const ALL: [Service; 3] = [Service::Metrics, Service::Logs, Service::Dashboard];
/// 应用 logo 的字节 —— **就是 exe 上那个图标本身**。
///
/// 和 `crates\nfarms-gui\src\logo.rs` 里 `include_bytes!` 的是同一个文件:
/// 「用软件的 logo」在这里的字面意思就是「和窗口/任务栏上那个图标逐字节相同」,
/// 而不是照着它另画一个近似的。
///
/// `include_bytes!` 的相对路径是按**本文件所在目录**算的:
/// `crates\nfarms-core\src\` 往上三级 = 仓库根,再进 `assets\`。
const NFARMS_ICO: &[u8] = include_bytes!("../../../assets/nfarms.ico");
/// logo 在 Grafana `public\` 下的**相对路径**。
///
/// 这一份字符串有两个用处:决定文件落盘到哪([`branding_logo_path`]),以及
/// `[branding]` 里那三个键写什么。两处必须是同一个值 —— 只在一边写死的话,
/// 以后改名字会出现「文件搬过去了、配置还指着老路径」,而现象是登录页上一张
/// 破图,不报任何错。
const LOGO_REL_PATH: &str = "public/img/nfarms-logo.ico";
/// 追加到 `custom.ini` 末尾的那一段 `[branding]`。
///
/// 三个键都用**不带前导斜杠**的相对路径 —— 和 Grafana 自己
/// `conf\defaults.ini` 里 `public/img/grafana_icon.svg` 的写法一致。带前导
/// 斜杠看着更"绝对",但 Grafana 前端会把它拼在 `appSubUrl` 后面,空 subUrl 时
/// 就成了 `//public/img/...` —— 那是个**协议相对 URL**,浏览器会去连一台叫
/// `public` 的主机,图直接不显示(而且控制台里看不出是路径写法的锅)。
///
/// 开头的 `\n` 是故意的:它保证这一段和上面原有的内容之间空一行。
const BRANDING_SECTION: &str = "\n[branding]\n\
app_title = NFarms HUB\n\
login_logo = public/img/nfarms-logo.ico\n\
menu_logo = public/img/nfarms-logo.ico\n\
favicon = public/img/nfarms-logo.ico\n";
/// Grafana 看板本体(uid `nfarms-overview`)。
///
/// 写成一段**静态 JSON** 而不是用 `serde_json::json!` 现场搭:这份东西的归宿是
/// 「能直接贴进 Grafana 的导入框」,保持它和 Grafana 导出的样子一致(缩进、键序、
/// 面板顺序)比在 Rust 里拼装更重要 —— 拼出来的那份只有编译一次才知道长什么样。
/// 代价是拼错了编译期看不出来,所以测试里把它 `serde_json::from_str` 解一遍。
///
/// ## 只画**真的存在**的指标
///
/// 程序目前只推 `nfarms_tokens_total{kind="prompt"|"completion"}` 这一条指标
/// (见 `crates\nfarms-gui\src\lib.rs` 的 `token_metric_line`),**没有任何系统
/// 指标**。所以这里一个 CPU/内存/磁盘的面板都没有 —— 空面板会显示 "No data",
/// 用户分不清那是「还没数据」还是「这东西坏了」,不如不放。
///
/// ## 面板坐标
///
/// 24 列网格,4 个面板两行两列,`h` 全是 8:
/// `(0,0,12×8)` `(12,0,12×8)` / `(0,8,12×8)` `(12,8,12×8)` —— 互不重叠。
const DASHBOARD_JSON: &str = r#"{
"uid": "nfarms-overview",
"title": "NFarms HUB",
"tags": ["nfarms"],
"timezone": "browser",
"schemaVersion": 41,
"version": 1,
"refresh": "30s",
"editable": true,
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"templating": {
"list": []
},
"annotations": {
"list": []
},
"panels": [
{
"id": 1,
"type": "stat",
"title": "Token 累计(按 kind)",
"description": "程序启动到现在推给 VictoriaMetrics 的 token 总数。程序只推 nfarms_tokens_total{kind=\"prompt\"} 和 {kind=\"completion\"} 两条,所以这里也只有这两个数。",
"datasource": { "type": "prometheus", "uid": "nfarms-metrics" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "palette-classic" }
},
"overrides": []
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"textMode": "auto",
"orientation": "auto"
},
"targets": [
{
"refId": "A",
"expr": "sum(nfarms_tokens_total{kind=\"prompt\"})",
"legendFormat": "输入 prompt",
"instant": true,
"range": false,
"datasource": { "type": "prometheus", "uid": "nfarms-metrics" }
},
{
"refId": "B",
"expr": "sum(nfarms_tokens_total{kind=\"completion\"})",
"legendFormat": "输出 completion",
"instant": true,
"range": false,
"datasource": { "type": "prometheus", "uid": "nfarms-metrics" }
}
]
},
{
"id": 2,
"type": "timeseries",
"title": "Token 增量速率(每分钟)",
"description": "rate(nfarms_tokens_total[5m]) × 60 = 每分钟新增的 token 数。线断掉的那段 = 没在推送,不是归零。",
"datasource": { "type": "prometheus", "uid": "nfarms-metrics" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 1,
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 2,
"fillOpacity": 10,
"showPoints": "never",
"spanNulls": false
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true,
"calcs": ["lastNotNull", "max"]
},
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"refId": "A",
"expr": "sum by (kind) (rate(nfarms_tokens_total[5m])) * 60",
"legendFormat": "{{kind}}",
"datasource": { "type": "prometheus", "uid": "nfarms-metrics" }
}
]
},
{
"id": 3,
"type": "timeseries",
"title": "输入 Token 累计(prompt)",
"description": "喂给模型的输入 token 累计曲线(只增不减)。看速率看右上那张,这张只看总量。",
"datasource": { "type": "prometheus", "uid": "nfarms-metrics" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"custom": {
"drawStyle": "line",
"lineInterpolation": "linear",
"lineWidth": 2,
"fillOpacity": 10,
"showPoints": "never",
"spanNulls": false
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true,
"calcs": ["lastNotNull"]
},
"tooltip": { "mode": "single", "sort": "none" }
},
"targets": [
{
"refId": "A",
"expr": "sum(nfarms_tokens_total{kind=\"prompt\"})",
"legendFormat": "输入 prompt",
"datasource": { "type": "prometheus", "uid": "nfarms-metrics" }
}
]
},
{
"id": 4,
"type": "timeseries",
"title": "输出 Token 累计(completion)",
"description": "模型吐出来的 token 累计曲线。和输入那张分开画:两者差着数量级时合在一张图上小的那条会贴地。",
"datasource": { "type": "prometheus", "uid": "nfarms-metrics" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"custom": {
"drawStyle": "line",
"lineInterpolation": "linear",
"lineWidth": 2,
"fillOpacity": 10,
"showPoints": "never",
"spanNulls": false
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true,
"calcs": ["lastNotNull"]
},
"tooltip": { "mode": "single", "sort": "none" }
},
"targets": [
{
"refId": "A",
"expr": "sum(nfarms_tokens_total{kind=\"completion\"})",
"legendFormat": "输出 completion",
"datasource": { "type": "prometheus", "uid": "nfarms-metrics" }
}
]
}
]
}
"#;
// ---------------------------------------------------------------- 公开类型
/// 可观测性三件套里的一件。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Service {
/// VictoriaMetrics —— 指标(时序库)
Metrics,
/// Loki —— 日志
Logs,
/// Grafana —— 看板(把上面两个接起来给人看)
Dashboard,
}
/// 一件服务的启动描述。
///
/// 字段全部公开、且都是**值**(不借任何东西),这样调用方可以直接把它丢进
/// 后台 Job 的闭包里 —— 起进程和等就绪是两件事、节奏也不同(起进程立刻返回,
/// 等就绪要几十秒),分成两步才好在界面上分别反馈。
#[derive(Debug, Clone)]
pub struct Spec {
/// 哪一件
pub service: Service,
/// 给人看的名字
pub name: &'static str,
/// 可执行文件的**绝对路径**
pub exe: PathBuf,
/// 启动参数(不带程序名)
pub args: Vec<String>,
/// 健康检查地址
pub health_url: String,
/// 端口(和 `args` / 配置里的端口是同一个值)
pub port: u16,
/// 实测就绪耗时的上界(秒),用来算等待预算
pub ready_hint_secs: u64,
}
// ---------------------------------------------------------------- 探测
fn service_name(service: Service) -> &'static str {
match service {
// 名字用组件自己的拼法:界面上要和它们的日志/文档对得上
Service::Metrics => "VictoriaMetrics",
Service::Logs => "Loki",
Service::Dashboard => "Grafana",
}
}
/// exe 在 `libs\` 下的位置。
///
/// 单独一个函数(而不是在 [`spec`] 里拼)是为了让 [`missing`] 用得**同一份**
/// 路径规则 —— 两处各拼一遍的话,以后 Grafana 换了目录层级会出现
/// 「missing 说齐了、spec 却返回 None」这种自相矛盾的状态。
fn exe_path(service: Service, libs: &Path) -> PathBuf {
match service {
Service::Metrics => libs.join("victoria-metrics-windows-amd64-prod.exe"),
Service::Logs => libs.join("loki-windows-amd64.exe"),
// Grafana 是**目录式**分发的:exe 在 `grafana\bin\` 下,同级还有
// conf\ / public\ 等一堆资源,所以 homepath 必须是这个目录(见 spec)
Service::Dashboard => libs.join("grafana").join("bin").join("grafana.exe"),
}
}
/// 缺哪些服务。返回的是**服务名**(与 [`Spec::name`] 同一份字符串),
/// 空 = 三件套齐了。
///
/// 返回名字而不是路径:调用方几乎总是拿它拼一句给用户看的话
/// (「缺 VictoriaMetrics 和 Loki,跑一下 fetch-libs.ps1」),而 exe 的绝对路径
/// 又长又和用户的动作无关。真要路径,`spec()` 里有。
pub fn missing(libs: &Path) -> Vec<&'static str> {
ALL.iter()
.filter(|s| !exe_path(**s, libs).is_file())
.map(|s| service_name(*s))
.collect()
}
/// 默认端口:9091 / 9092 / 9093(实测值,和 `defaults.ini` / 现有配置一致)。
pub fn default_port(service: Service) -> u16 {
match service {
Service::Metrics => METRICS_PORT,
Service::Logs => LOGS_PORT,
Service::Dashboard => DASHBOARD_PORT,
}
}
/// 拼出一件服务的启动描述;**exe 不在就返回 `None`,不 panic**。
///
/// 「找不到」是**常态**而不是异常:`libs\` 里没有三件套的机器上,界面要照常
/// 能用(那一页显示「未安装」)。做成 `panic` 或 `Result` 的话,每个调用点都得
/// 先自己判一次存在性 —— 而存在性判定要用同一份路径规则,早晚会有人漏判。
///
/// # `port` 与配置文件的关系(易错点)
///
/// `port` 一处进 `args`/`health_url`:`spec` 只把它写进 **VM 的
/// `-httpListenAddr`** 和健康检查地址。Loki 的监听端口在
/// `loki-config.yaml` 的 `server.http_listen_port`、Grafana 的在
/// `custom.ini` 的 `[server] http_port` 里 —— 也就是说 **`spec` 不会去改配置
/// 文件**,它只是「相信调用方给的 port 和配置文件里的一致」。
/// 调用方要保证这一点:用同一个 `port` 调 [`ensure_configs`] 生成配置、调
/// [`spec`] 拼命令行,两边不一致就会出现「健康检查打在 A 端口、服务听着 B 端口」
/// 这种永远等不到就绪的状态。
pub fn spec(service: Service, libs: &Path, data: &Path, port: u16) -> Option<Spec> {
let exe = exe_path(service, libs);
// 用 `is_file` 而不是 `exists`:同名目录会让 spawn 报一个很难懂的
// 「拒绝访问」,在这里就判掉更省事
if !exe.is_file() {
return None;
}
let (args, health_url, ready_hint_secs) = match service {
Service::Metrics => {
let storage = data.join("vm");
(
vec![
"-storageDataPath".to_string(),
storage.display().to_string(),
"-httpListenAddr".to_string(),
format!(":{port}"),
],
format!("http://127.0.0.1:{port}/health"),
// 实测 1 秒(空库启动,不建索引)
1,
)
}
Service::Logs => {
let config = loki_config_path(data);
(
vec![
"-config.file".to_string(),
config.display().to_string(),
],
format!("http://127.0.0.1:{port}/ready"),
// **实测 17 秒**(要建 TSDB shipper 目录、开 ring、加载 schema)
17,
)
}
Service::Dashboard => {
let home = libs.join("grafana");
let config = grafana_ini_path(data);
(
vec![
"server".to_string(),
"--homepath".to_string(),
home.display().to_string(),
"--config".to_string(),
config.display().to_string(),
],
format!("http://127.0.0.1:{port}/api/health"),
// 实测 7 秒(要迁移 SQLite、加载插件与默认看板)
7,
)
}
};
Some(Spec {
service,
name: service_name(service),
exe,
args,
health_url,
port,
ready_hint_secs,
})
}
// ---------------------------------------------------------------- 起进程
/// 起一个服务,**不等待**它就绪(就绪交给 [`wait_ready`],那一步要几十秒,
/// 调用方必须放进后台 Job)。
///
/// 这是冻结清单之外的一个补充:清单里没有「起进程」这一步,但启动三件套的
/// 命令行只有本模块知道([`Spec`]),而且**这条 `Command` 必须带
/// `CREATE_NO_WINDOW`** —— 少了它,GUI 里每次点「启动」都会先闪一个控制台黑框。
/// 调用方仍可以自己拿 `spec.exe` / `spec.args` 去 `Command`;但那就得自己记得
/// 加那个标志位,正是这个模块存在的意义。
///
/// # 三件套的 stdout/stderr 接到 `NULL`,不接管道
///
/// **接 `piped` 而不读 = 死锁**:管道缓冲区(几 KB)一满,子进程的 `write` 就
/// 永久阻塞,而 GUI 这边根本不读 —— Loki / Grafana 启动时话很多,几秒钟就能
/// 把缓冲区写满,于是「服务明明在跑却永远不 ready」。真要留日志,给它们自己
/// 的日志开关,别在这里改成管道。
///
/// 也不去改 `current_dir`:这里拼出来的就是实测过能跑的那条命令行,
/// 额外设工作目录只会引入「和实测不一样」的变量(参数里的路径全是绝对的)。
///
/// 起出来的子进程会被挂进**这一件服务自己的** Job Object(见模块头那段
/// "进程回收"),挂失败不挡启动,只留一句 [`take_job_warning`]。
pub fn start(spec: &Spec) -> Result<Child, String> {
if !spec.exe.is_file() {
return Err(format!(
"{} 的可执行文件不在:{}(跑一下 libs\\scripts\\fetch-libs.ps1)",
spec.name,
spec.exe.display()
));
}
let mut cmd = Command::new(&spec.exe);
cmd.args(&spec.args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(CREATE_NO_WINDOW);
}
let child = cmd
.spawn()
.map_err(|e| format!("起 {} 失败({}):{e}", spec.name, spec.exe.display()))?;
// 挂 Job 只能在 `spawn` **之后**:Job 认的是进程,不是命令行
#[cfg(windows)]
attach_to_job(spec, &child);
Ok(child)
}
// ---------------------------------------------------------------- 进程回收
/// 三件服务各自的 Job Object 句柄,**`0` = 还没建或建失败**。
///
/// 为什么是进程级 `static`,而不是像 `live.rs` 那样挂在某个结构体上:
/// [`start`] 的签名是 `Result<Child, String>`(界面直接把它当 `Child` 存进
/// `obs_proc`),没有任何地方能安放一个"和三件服务同寿命"的所有者。
/// 放在这里等于**句柄和启动器进程一样长命** —— 而"启动器被结束/崩溃时谁来收"
/// 恰好只能靠内核关句柄这一下,这正是要覆盖的场景。
///
/// 下标由 [`job_slot`] 定,**顺序必须和 `ALL` 一致**(有测试盯着)。
#[cfg(windows)]
static JOBS: [AtomicUsize; 3] = [AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0)];
/// 挂 Job / 建 Job 失败时留下的那句话(`None` = 没出过事)。
///
/// `start()` 既不能改签名、又**不许**因为挂不上 Job 就挡住服务启动,这句话
/// 就没了返回值的去处;而 `nfarms-core` 这里也不认识 `Log` 的实例。
/// 调用方起完服务顺手取一次写进日志即可(见模块头的例子)。
static JOB_WARNING: Mutex<Option<String>> = Mutex::new(None);
/// `Service` → [`JOBS`] 的下标。**顺序和 `ALL` 是同一份**。
#[cfg(windows)]
fn job_slot(service: Service) -> usize {
match service {
Service::Metrics => 0,
Service::Logs => 1,
Service::Dashboard => 2,
}
}
/// 懒建 Job 并复用(和 `live.rs::Live::kill_on_close_job` 同一套做法)。
///
/// 建不出来返回 `0` —— 调用方拿到 0 就按"没人回收"处理,不能因此起不来服务。
#[cfg(windows)]
fn job_for(service: Service) -> usize {
let slot = &JOBS[job_slot(service)];
let existing = slot.load(Ordering::Acquire);
if existing != 0 {
return existing;
}
let fresh = match job_win::create() {
Ok(handle) => handle,
// 建不出来通常是"本进程已经在一个不允许嵌套的 Job 里"(CI / 任务计划
// 程序起进程时很常见)。退回"不回收"的旧行为,但**留话** ——
// 静默降级正是那 91 个孤儿当初没人发现的原因
Err(code) => {
note_job_warning(format!(
"{} 的进程回收不可用:建 Job Object 失败(Win32 错误码 {code}),\
它派生的子进程将来不会被自动结束",
service_name(service)
));
return 0;
}
};
// 并发下可能已经被别的线程建好了:用它的,把自己这份空的关掉
//(里面还没有进程,关掉不会牵连谁)
match slot.compare_exchange(0, fresh, Ordering::AcqRel, Ordering::Acquire) {
Ok(_) => fresh,
Err(other) => {
job_win::close(fresh);
other
}
}
}
/// 把刚起来的子进程挂到**这一件服务自己的** Job 上。
///
/// 失败只记一句话,**不影响启动**:起得来比回收得了重要,和
/// `live.rs::Live::spawn_attached` 一个口径。
#[cfg(windows)]
fn attach_to_job(spec: &Spec, child: &Child) {
let job = job_for(spec.service);
if job == 0 {
// `job_for` 那边已经把原因写下了
return;
}
if let Err(code) = job_win::assign(job, child) {
note_job_warning(format!(
"{} 没能挂进 Job Object(pid {},Win32 错误码 {code}):\
它派生的子进程(如 Grafana 的 gpx_* 插件后端)将来不会被自动结束",
spec.name,
child.id()
));
}
}
/// 关掉某一件服务的 Job 句柄 —— **内核随即结束 Job 里的所有进程**。
///
/// 这是 `obs_stop()` 要补的那一下:`Child::kill()` 只打直接子进程,Grafana
/// 派生出来的 13 个插件后端要靠这句话收走。不做"先礼后兵",因为
/// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` 的语义就是句柄一关就杀 ——
/// 和 `live.rs` 里 `Drop for Live` 走的是同一条路。
///
/// 关完把槽清成 `0`:`start()` 下次会**重新建一个** Job。不清的话它会拿着
/// 一个已经关掉的句柄去 `AssignProcessToJobObject`,那个失败还看不出来。
///
/// 非 Windows 上是个空操作 —— 那边没有 Job Object,`Child::kill()` 就是全部
/// (Android 也要编过,所以这个函数在哪个平台上都存在、签名一样)。
#[cfg_attr(not(windows), allow(unused_variables))]
pub fn close_job(service: Service) {
#[cfg(windows)]
{
let old = JOBS[job_slot(service)].swap(0, Ordering::AcqRel);
if old != 0 {
job_win::close(old);
}
}
}
/// 取走"进程回收降级了"那句话(**取过一次就没了**)。
///
/// 调用方:起完服务顺手取一次,非 `None` 就写进日志 —— 界面每帧都会轮询,
/// 做成"取走"而不是"读一下",是为了不把同一句话写进日志几十遍。
pub fn take_job_warning() -> Option<String> {
// 锁中毒(别人持锁时 panic 了)不该在这里再炸一次:取不到就当没有
JOB_WARNING.lock().ok().and_then(|mut slot| slot.take())
}
/// 记一句"回收降级了"的话,并顺手打到 stderr。
///
/// stderr 那一份是给**没有接日志的调用方**留的(比如直接跑这个 crate 的
/// 测试/自检);界面那边读 [`take_job_warning`]。
#[cfg(windows)]
fn note_job_warning(msg: String) {
eprintln!("[observability] {msg}");
if let Ok(mut slot) = JOB_WARNING.lock() {
*slot = Some(msg);
}
}
/// 只给测试用:这一件服务的 Job 句柄(`0` = 还没有)。
#[cfg(all(test, windows))]
fn job_handle(service: Service) -> usize {
JOBS[job_slot(service)].load(Ordering::Acquire)
}
// ---------------------------------------------------------------- 等就绪
/// 等一件服务真的能服务。**阻塞**,调用方放进后台 Job。
///
/// 判定口径很窄:**健康检查返回 2xx**。其它一律当「还没好」继续轮询 ——
/// Loki 在起 TSDB 的途中会回 503,那不是失败,是「再等等」;把非 2xx 当失败
/// 会让 17 秒的 Loki 每次都被判成起不来。
///
/// 超时返回 `Err`,**不会挂死**:没有 deadline 的话,一个永远起不来的服务
/// 会让那个后台 Job 永远不结束,界面上就是转圈转到用户重启程序
/// (`weather.rs` / `chat.rs` 都是这个形态的 bug)。
pub fn wait_ready(spec: &Spec) -> Result<(), String> {
wait_ready_with(spec, ready_budget(spec))
}
/// 就绪预算 = `ready_hint_secs × 2 + 2` 秒。
///
/// 为什么不是写死一个数:Loki 实测 **17 秒**,拍一个「5 秒够了」的话它每次都
/// 被判成失败 —— 而它其实在正常启动,用户看到的却是「日志服务起不来」,
/// 于是去查一个根本不存在的故障。×2 + 2 给 17 秒的 Loki 留 36 秒余量,
/// 而 1 秒的 VM 只等 4 秒:预算跟着每个服务的实测耗时走,**不让快的陪慢的等**。
///
/// 用 `saturating_*` 是防守:这两个乘加用的是外部给的 `ready_hint_secs`,
/// 溢出会在 release 下变成 0 秒预算,那就是「怎么等都是失败」。
fn ready_budget(spec: &Spec) -> Duration {
Duration::from_secs(spec.ready_hint_secs.saturating_mul(2).saturating_add(2))
}
/// [`wait_ready`] 的本体,预算当参数传。
///
/// 分开只为了让测试能用几百毫秒真的跑完这条路径(拿真的 36 秒去测 Loki,
/// 测试跑不动,而「跑不动所以不测」正是超时相关的 bug 会漏掉的原因)。
fn wait_ready_with(spec: &Spec, budget: Duration) -> Result<(), String> {
let agent: ureq::Agent = ureq::Agent::config_builder()
// 「单次」超时,不是总预算 —— 见 HEALTH_REQUEST_TIMEOUT_SECS
.timeout_global(Some(Duration::from_secs(HEALTH_REQUEST_TIMEOUT_SECS)))
.build()
.into();
let started = Instant::now();
let deadline = started + budget;
loop {
// 这一轮探测的结果,**只在这一轮里用**(超时那条报错里要带上它),
// 所以不跨轮保存。跨轮保存的话第一轮的初值永远读不到,
// rustc 会直接报 `unused_assignments` —— 那不是在挑剔,是在指出
// 「你以为有个默认值兜底,其实没有」。
let outcome = match agent.get(&spec.health_url).call() {
Ok(resp) => {
let code = resp.status().as_u16();
if (200..300).contains(&code) {
return Ok(());
}
format!("HTTP {code}")
}
// 4xx/5xx 不是「连不上」,而是「在跑但还没好」:Loki 起 TSDB 时
// /ready 回 503 就落在这一支
Err(ureq::Error::StatusCode(code)) => format!("HTTP {code}"),
// 连接被拒 / 超时 / DNS ……都当「还没好」,下一轮再试
Err(e) => e.to_string(),
};
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(format!(
"{} 等了 {} 秒还没就绪(健康检查 {} 最后一次是:{outcome})—— \
进程多半没起来或是起崩了",
spec.name,
started.elapsed().as_secs(),
spec.health_url
));
}
// 睡不超过剩余预算:否则最后那一下会白睡过去,报出来的等待时间
// 比调用方给的预算长一截
std::thread::sleep(POLL_INTERVAL.min(remaining));
}
}
// ---------------------------------------------------------------- 喂数据
/// 造一个带超时的 agent。
///
/// **每一个** HTTP 调用点都走这里,不允许出现裸的 `ureq::get/post`:
/// ureq 默认不设超时,漏一处就是一个可以永久卡住 Job 的入口。
fn agent() -> ureq::Agent {
ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(PUSH_TIMEOUT_SECS)))
.build()
.into()
}
/// POST 一段文本,2xx 算成功。
fn post(url: &str, content_type: &str, body: &str) -> Result<(), String> {
let mut resp = agent()
.post(url)
.header("Content-Type", content_type)
.send(body)
.map_err(|e| match e {
// 服务在跑、但拒了这条数据(格式/权限/限流)。和「连不上」分开报,
// 因为处理方式完全不同
ureq::Error::StatusCode(code) => {
format!("服务端返回 HTTP {code}(服务在跑,但拒了这条数据)")
}
other => other.to_string(),
})?;
// 响应体要读掉:不读的话这条连接用不了(ureq 只能把它丢掉),高频推送就会
// 每次重新握手。顺带把服务端的话留进错误里(204 没有正文,就是空串)
resp.body_mut()
.read_to_string()
.map_err(|e| format!("读响应失败: {e}"))?;
Ok(())
}
/// 往 VictoriaMetrics 推一行 Prometheus 文本格式的指标。
///
/// 走 `POST /api/v1/import/prometheus`,`body` **就是那一行文本**(不加换行、
/// 不加包裹):这个接口收的就是裸的 exposition 格式,包成 JSON 反而要它报 400。
/// 一行一个样本,多行请多次调用 —— 每次调用都是一次独立的小写入,
/// 失败也只丢那一行。
pub fn push_metric(vm_port: u16, line: &str) -> Result<(), String> {
let url = format!("http://127.0.0.1:{vm_port}/api/v1/import/prometheus");
post(&url, "text/plain; charset=utf-8", line)
.map_err(|e| format!("推指标到 {url} 失败:{e}"))
}
/// 现在这一刻的 Unix 纳秒时间戳(字符串)。
///
/// 单独一个函数是因为 Loki 对时间戳的形态很挑:必须是**字符串**形式的纳秒。
/// 用整数或秒级时间戳,服务端一律 400。
fn now_nanos() -> Result<String, String> {
let d = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| format!("系统时间早于 1970-01-01,推日志要用纳秒时间戳:{e}"))?;
Ok(d.as_nanos().to_string())
}
/// 拼 Loki 的推送体。纯函数(不联网、不取时间),单独拎出来是为了能直接
/// 断言 JSON 的形状 —— 形状错了服务端只回一个 400,看不出是哪一层错的。
fn log_payload(labels: &[(&str, &str)], line: &str, ts_nanos: &str) -> String {
let mut stream = serde_json::Map::new();
for (k, v) in labels {
stream.insert((*k).to_string(), serde_json::Value::String((*v).to_string()));
}
serde_json::json!({
"streams": [{
"stream": serde_json::Value::Object(stream),
"values": [[ts_nanos, line]],
}]
})
.to_string()
}
/// 往 Loki 推一行日志。
///
/// `streams[].stream` 是标签集,`values` 是 `[纳秒时间戳, 日志行]` 的数组 ——
/// 时间戳**必须是字符串**:Loki 收的是纳秒整数,但 JSON 里的数字会被当成
/// 浮点(double 只有 53 位精度,存不下 19 位的纳秒),所以协议规定写成字符串。
///
/// 标签集**不能为空**:Loki 的硬要求是每个 stream 至少一对标签,空标签集
/// 会被 400 掉。这里提前判掉并给一句人话,免得用户对着一个 400 猜是哪里错了。
pub fn push_log(loki_port: u16, labels: &[(&str, &str)], line: &str) -> Result<(), String> {
if labels.is_empty() {
return Err("推日志至少要一个标签(Loki 规定每个 stream 至少一对标签)".to_string());
}
let ts = now_nanos()?;
let body = log_payload(labels, line, &ts);
let url = format!("http://127.0.0.1:{loki_port}/loki/api/v1/push");
post(&url, "application/json", &body).map_err(|e| format!("推日志到 {url} 失败:{e}"))
}
// ---------------------------------------------------------------- 配置落盘
/// Loki 配置的位置 —— `data\loki\loki-config.yaml`。
fn loki_config_path(data: &Path) -> PathBuf {
data.join("loki").join("loki-config.yaml")
}
/// Grafana 的覆盖配置 —— `data\grafana\custom.ini`。
fn grafana_ini_path(data: &Path) -> PathBuf {
data.join("grafana").join("custom.ini")
}
/// Grafana 的数据源文件 —— `data\grafana\provisioning\datasources\nfarms.yml`。
fn datasources_path(data: &Path) -> PathBuf {
data.join("grafana")
.join("provisioning")
.join("datasources")
.join("nfarms.yml")
}
/// Grafana 的看板 provider 配置 ——
/// `data\grafana\provisioning\dashboards\nfarms.yml`。
///
/// 注意这是**两层不同的东西**:`provisioning\dashboards\` 下的是"去哪读看板"
/// 的注册文件,真正的看板在 `data\grafana\dashboards\`(见 [`dashboard_json_path`])。
/// 少一层目录就会出现「provider 指着一个空目录」,现象是 Grafana 里一个看板都没有,
/// 而文件明明写出来了。
fn dashboards_provider_path(data: &Path) -> PathBuf {
data.join("grafana")
.join("provisioning")
.join("dashboards")
.join("nfarms.yml")
}
/// Grafana 的看板本体 —— `data\grafana\dashboards\nfarms.json`。
fn dashboard_json_path(data: &Path) -> PathBuf {
data.join("grafana").join("dashboards").join("nfarms.json")
}
/// 品牌 logo 的落盘位置 —— `<libs>\grafana\public\img\nfarms-logo.ico`。
///
/// 各段**从 [`LOGO_REL_PATH`] 里拆出来拼**,不在这儿再写一遍字面量:那样就是
/// 两个地方各存一份真话,改了一边就会出现「文件搬到 A、`[branding]` 指着 B」,
/// 现象是登录页一张破图,不报任何错。
///
/// 用 `split('/')` 逐段 `push` 而不是直接 `join("public/img/…")`:后者在
/// Windows 上会拼出 `…\grafana\public/img/nfarms-logo.ico` 这种两种分隔符混着
/// 的路径 —— 能用,但看着像出错了,而且日志/错误信息里显示的就是它。
fn branding_logo_path(libs: &Path) -> PathBuf {
let mut p = libs.join("grafana");
for seg in LOGO_REL_PATH.split('/') {
p.push(seg);
}
p
}
/// 把路径写成 **YAML 里能用的正斜杠形式**。
///
/// Windows 的反斜杠在 YAML 的**单引号**标量里本来也是字面量,但这个文件将来
/// 可能被别的手写工具/双引号标量读(那里 `\C` 是转义序列,会直接把 YAML 解析
/// 搞崩),而且 Loki 官方文档里的路径全是正斜杠 —— 统一成正斜杠,少一类
/// 「在 Linux 上没问题、在 Windows 上解析报错」的坑。
fn yaml_path(p: &Path) -> String {
p.display().to_string().replace('\\', "/")
}
/// Loki 的单机最小配置(**内容照抄已实测通过的那一份**,只把路径和
/// HTTP 端口换成参数)。
fn loki_config_yaml(data: &Path, loki_port: u16) -> String {
let base = yaml_path(&data.join("loki"));
// gRPC 端口跟着 HTTP 端口走(+3,和现有那份 9092→9095 的关系一致):
// 写死 9095 的话,一旦有人把 HTTP 端口改开第二份实例,两份会在 gRPC 端口上
// 撞车 —— 而报错信息只是「address already in use」,看不出是这个原因
let grpc_port = loki_port.saturating_add(3);
format!(
"# nfarms 用的最小 Loki 单机配置。\n\
# 由程序在启动时生成 —— 这个文件是**运行产物**,不放进 libs\\(那不是库)。\n\
auth_enabled: false\n\
\n\
server:\n\
\x20 http_listen_port: {loki_port}\n\
\x20 grpc_listen_port: {grpc_port}\n\
\n\
common:\n\
\x20 path_prefix: '{base}'\n\
\x20 storage:\n\
\x20 filesystem:\n\
\x20 chunks_directory: '{base}/chunks'\n\
\x20 rules_directory: '{base}/rules'\n\
\x20 replication_factor: 1\n\
\x20 ring:\n\
\x20 kvstore:\n\
\x20 store: inmemory\n\
\n\
schema_config:\n\
\x20 configs:\n\
\x20 - from: 2024-01-01\n\
\x20 store: tsdb\n\
\x20 object_store: filesystem\n\
\x20 schema: v13\n\
\x20 index:\n\
\x20 prefix: index_\n\
\x20 period: 24h\n\
\n\
limits_config:\n\
\x20 allow_structured_metadata: true\n\
\n\
# 单机不需要副本和限流那套,保持默认即可"
)
}
/// Grafana 的覆盖配置(**内容照抄已实测通过的那一份**,只换路径与端口)。
///
/// 这里的路径**用反斜杠**:`.ini` 不是 YAML,没有转义问题,而 Grafana 在
/// Windows 上对两种写法都认 —— 保持和现有那份逐字节一致更重要。
fn grafana_ini(data: &Path, grafana_port: u16) -> String {
let g = data.join("grafana");
let dir = g.display();
format!(
"; nfarms 用的 Grafana 覆盖配置(叠加在 conf\\defaults.ini 之上)\n\
[paths]\n\
data = {dir}\n\
logs = {dir}\\logs\n\
plugins = {dir}\\plugins\n\
provisioning = {dir}\\provisioning\n\
\n\
[server]\n\
http_port = {grafana_port}\n\
; 只监听回环 —— 这是本机自用的可视化,没有理由暴露到局域网\n\
http_addr = 127.0.0.1\n\
\n\
[security]\n\
; 首次启动要设管理员密码,否则 Grafana 会强制走改密流程\n\
admin_user = admin\n\
admin_password = nfarms\n\
\n\
[analytics]\n\
; **关掉上报。** 这个项目的立场是\"隐私都留在本地\",不该反过来往 Grafana 发遥测\n\
reporting_enabled = false\n\
check_for_updates = false\n\
check_for_plugin_updates = false"
)
}
/// Grafana 的两个数据源(**指标 → Loki**),uid 固定为 `nfarms-metrics` /
/// `nfarms-logs`。
///
/// uid **写死**而不是让 Grafana 自己生成:看板 JSON 里引用数据源用的是 uid,
/// 每次重新生成都变的话,预置看板会指向一个不存在的数据源(界面表现为
/// 「Datasource not found」,而数据源明明在列表里)。
fn datasources_yaml(vm_port: u16, loki_port: u16) -> String {
format!(
"# nfarms 的 Grafana 数据源 —— 由程序生成,别手改(改了会被下一次生成覆盖)。\n\
apiVersion: 1\n\
\n\
datasources:\n\
\x20 - name: NFarms Metrics\n\
\x20 uid: nfarms-metrics\n\
\x20 type: prometheus\n\
\x20 access: proxy\n\
\x20 url: http://127.0.0.1:{vm_port}\n\
\x20 isDefault: true\n\
\x20 jsonData:\n\
\x20 httpMethod: POST\n\
\x20 timeInterval: 5s\n\
\x20 - name: NFarms Logs\n\
\x20 uid: nfarms-logs\n\
\x20 type: loki\n\
\x20 access: proxy\n\
\x20 url: http://127.0.0.1:{loki_port}"
)
}
/// Grafana 的看板 provider 配置(**字段名照 Grafana 13 的口径写**)。
///
/// * `folder: nfarms` —— 预置的看板归到一个叫 nfarms 的目录里,别和 Grafana
/// 自带的那些混在一起(那些在 General 里,几十张,找自己的看板很费劲)。
/// * `type: file` + `options.path` —— 告诉它去哪个目录扫 `.json`。**路径用正
/// 斜杠**(见 [`yaml_path`])。
/// * `disableDeletion: false` + `allowUiUpdates: true` —— 允许在界面上直接改并
/// 保存(改完写回 `data\grafana\dashboards\`)。下一节 [`provision_dashboards`]
/// 是幂等的,内容没变就不会重写,所以用户的改动不会被程序启动时抹掉;
/// 真要回到出厂的那一版,把那个 json 删掉再启动程序。
/// * `updateIntervalSeconds: 30` —— Grafana 每 30 秒重扫一次目录,改完文件不用
/// 重启 Grafana 就能看到(和 `custom.ini` 不同,那个必须重启)。
fn dashboards_provider_yaml(data: &Path) -> String {
let path = yaml_path(&data.join("grafana").join("dashboards"));
format!(
"# nfarms 的 Grafana 看板 provider —— 由程序生成,别手改(改了会被下一次生成覆盖)。\n\
# 看板本体在 data/grafana/dashboards/ 下,这里只说去哪个目录读(路径统一用正斜杠)。\n\
apiVersion: 1\n\
\n\
providers:\n\
\x20 - name: nfarms\n\
\x20 folder: nfarms\n\
\x20 type: file\n\
\x20 disableDeletion: false\n\
\x20 updateIntervalSeconds: 30\n\
\x20 allowUiUpdates: true\n\
\x20 options:\n\
\x20 path: {path}"
)
}
/// 同目录下的 `.tmp` 兄弟路径。
///
/// **必须同目录**:跨盘/跨卷的 `rename` 不是原子替换(Windows 上直接报错,
/// Unix 上退化成 copy+delete),而这个写法的全部意义就是那一下原子替换。
fn tmp_sibling(path: &Path) -> PathBuf {
let mut name = path.file_name().map(|s| s.to_os_string()).unwrap_or_default();
name.push(".tmp");
match path.parent() {
Some(dir) => dir.join(name),
None => PathBuf::from(name),
}
}
/// 写文件:**先写 `.tmp` 再 `rename` 顶替**。
///
/// 直接 `fs::write` 会先把原文件截断,写到一半失败(磁盘满 / 进程被杀)就只剩
/// 半份配置 —— 而半份 YAML 会让 Loki 起不来,用户还得先猜是配置坏了。
/// 同目录内的 `rename` 是原子的:要么看到旧的完整版,要么看到新的完整版。
///
/// 收 `impl AsRef<[u8]>` 而不是 `&str`:品牌 logo 是**二进制**,走的是同一套
/// `.tmp` + `rename`(半份图标文件的症状是一张破图,比半份配置更难看懂)。
fn write_atomic(path: &Path, bytes: impl AsRef<[u8]>) -> Result<(), String> {
if let Some(dir) = path.parent() {
if !dir.as_os_str().is_empty() {
std::fs::create_dir_all(dir)
.map_err(|e| format!("建目录 {} 失败: {e}", dir.display()))?;
}
}
let tmp = tmp_sibling(path);
if let Err(e) = std::fs::write(&tmp, bytes) {
// 写坏的临时文件要清掉:没人会来收拾它,而用户看到 `xxx.tmp` 只会更困惑
let _ = std::fs::remove_file(&tmp);
return Err(format!("写临时文件 {} 失败: {e}", tmp.display()));
}
if let Err(e) = std::fs::rename(&tmp, path) {
// 替换失败时原文件**一个字都没动**(rename 要么成功要么什么都没发生)
let _ = std::fs::remove_file(&tmp);
return Err(format!("替换 {} 失败: {e}", path.display()));
}
Ok(())
}
/// 幂等落盘:**内容一样就一个字节都不写**,只在不同时才走 [`write_atomic`]。
///
/// 抽出来是因为「生成配置」这件事有三个调用点(数据源、看板 provider、看板本体),
/// 而幂等这件事的理由在每一处都成立 —— 见 [`provision_datasources`] 的注释。
/// 各写一遍的话早晚有一处漏掉判断,而漏掉的那处每次都 `rename` 一遍正在被
/// Grafana 热加载的目录。
fn write_if_changed(path: &Path, text: &str) -> Result<(), String> {
if std::fs::read_to_string(path).is_ok_and(|old| old == text) {
return Ok(());
}
write_atomic(path, text)
}
/// 写 Grafana 的数据源文件,**返回写到的路径**。
///
/// **幂等**:内容一样就一个字节都不写。这不只是省一次磁盘 IO ——
/// Grafana 是**在跑着的时候**读这个目录的(provisioning 会热加载),
/// 每次启动都 `rename` 一遍等于每次都给正在跑的 Grafana 一次「文件被替换」
/// 事件;而且内容没变时连 mtime 都不动,用户/工具看到的是「本来就配好了」。
///
/// 两个数据源的 uid 固定为 `nfarms-metrics` / `nfarms-logs`(理由见上面
/// `datasources_yaml` 的注释),url 指向 `127.0.0.1:<port>`:
/// 回环地址,和这两个库「只服务本机」的定位一致。
pub fn provision_datasources(data: &Path, vm_port: u16, loki_port: u16) -> Result<PathBuf, String> {
let path = datasources_path(data);
write_if_changed(&path, &datasources_yaml(vm_port, loki_port))?;
Ok(path)
}
/// 写 Grafana 的看板 provider 和看板本体,**返回看板 JSON 的路径**。
///
/// 两个文件,一层套一层(少一层就是「provider 指着一个空目录」,界面上一个
/// 看板都没有):
///
/// 1. `data\grafana\provisioning\dashboards\nfarms.yml` —— 注册文件,说清去哪
/// 读看板([`dashboards_provider_yaml`])
/// 2. `data\grafana\dashboards\nfarms.json` —— 看板本体(uid `nfarms-overview`,
/// 定义见 [`DASHBOARD_JSON`])
///
/// 两个文件都是**幂等**的(同一个 [`write_if_changed`]):内容一样就一个字节都
/// 不写。这一条在看板上比数据源更要紧 —— `allowUiUpdates` 打开时用户可能在
/// Grafana 里直接改过这张看板,程序每次启动都重写的话,等于每次启动都把他的
/// 改动抹掉(和 [`ensure_configs`] 不覆盖已有配置是同一条立场)。
///
/// 返回**看板 JSON** 的路径而不是 provider 的:调用方多半是想把「看板在这儿」
/// 显示给用户(甚至直接打开),而 provider 那个是纯注册用的,路径可以推出来。
pub fn provision_dashboards(data: &Path) -> Result<PathBuf, String> {
let provider = dashboards_provider_path(data);
write_if_changed(&provider, &dashboards_provider_yaml(data))?;
let dashboard = dashboard_json_path(data);
write_if_changed(&dashboard, DASHBOARD_JSON)?;
Ok(dashboard)
}
/// `custom.ini` 里是不是已经有 `[branding]` 段了(ini 的段名大小写不敏感)。
///
/// 只看**段名**、不看内容,而且跳过注释行:用户手改过这一段的键(比如把标题
/// 换成别的)就照用户的来 —— 和 [`ensure_configs`] 同一条立场(生成缺省,
/// 不覆盖人的修改)。注释行要跳过是因为 `; [branding]` 这种"注释掉的段"
/// 也会被 `lines()` 捞出来,认成「已经有了」,于是这一段永远装不上。
fn has_branding_section(ini: &str) -> bool {
ini.lines()
.map(str::trim)
.filter(|l| !l.starts_with(';') && !l.starts_with('#'))
.any(|l| l.eq_ignore_ascii_case("[branding]"))
}
/// 把应用 logo 装进 Grafana,并让 `[branding]` 指向它;**返回 logo 的路径**。
///
/// # 选了哪条路:直接搬 `.ico`,不抽 PNG
///
/// `nfarms-gui` 里有一个现成的 `logo::extract_largest_png`,能把 ICO 里那张
/// 256×256 的 PNG 抽出来 —— 但 **crate 依赖方向不允许**:`nfarms-core` 是纯逻辑
/// 层、被 gui 依赖,反过来依赖 gui 就是循环依赖。而在 core 里再抄一份 ICO 解析
/// 是同一件事写两遍(ICO 恰好是最容易写错的那种二进制格式:目录项、偏移、
/// BITMAPINFOHEADER、AND mask,少看一个字段就解出半张图),所以这里**不抽**:
/// 直接把 `assets\nfarms.ico` 的字节搬进 Grafana 的 `public\img\`,让浏览器自己解。
///
/// 这条路成立是因为 ico 本来就是**浏览器原生认的图片格式**(favicon 就是它),
/// `<img src="*.ico">` 和 `<link rel="icon">` 走的是同一套解码器;代价是 Grafana
/// 那个位置会按 ICO 里最大的那张(256×256)自己缩放,不做尺寸裁剪 —— 换来的是
/// 「图和 exe 上那个图标逐字节相同」,而不是照它另画一个近似的。
///
/// # 为什么要往 `libs\` 里写 —— 这是本模块唯一的例外
///
/// `libs\` 是第三方组件的地盘(理由见模块开头),正常一个字节都不该往里放。
/// 但 Grafana **只从 homepath 的 `public\`** 提供静态文件(`/public/...`),
/// `[branding]` 里能指的也只有那儿 —— 所以 logo 这一份必须落在
/// `<libs>\grafana\public\img\nfarms-logo.ico`。**这是不得已的**:只要 Grafana
/// 还是"解压即用"的目录式分发,它就不认 `data\` 里的图。
///
/// 代价要说清:**重装/升级 `libs\` 会把这一份冲掉**(`fetch-libs.ps1` 覆盖整个
/// 目录),之后要重新 install 一次;这一步是幂等的,重复跑没有副作用。所以调用
/// 点应该放在"每次启动三件套之前"那条路径上,而不是只在安装时跑一次。
///
/// # `[branding]` 要**重启 Grafana** 才生效
///
/// `custom.ini` 是 Grafana **启动时**读一次的,provisioning 那套热加载管不到它。
/// 这一步写完之后已经跑着的 Grafana 不会变 —— 界面上就是「停止」再「启动」。
/// (看板不同:[`provision_dashboards`] 写的文件会被 provider 按
/// `updateIntervalSeconds` 自动重扫,不用重启。)
///
/// # 调用顺序:必须先 [`ensure_configs`]
///
/// `custom.ini` 不存在时这里**报错,而不是自己造一份**:造出来的那份没有
/// `[server] http_port`,Grafana 会退回默认的 3000,而健康检查还在打 9093 ——
/// 症状是「Grafana 永远起不来」,而真正的原因是一个缺了半段的配置。
pub fn install_branding(libs: &Path, data: &Path) -> Result<PathBuf, String> {
// 1. logo 落到 Grafana 的 public\ 下。按**字节**比而不是按长度/时间戳:
// 换一张图标但大小一样是很可能的(同一套设计改个颜色)
let logo = branding_logo_path(libs);
if !std::fs::read(&logo).is_ok_and(|old| old == NFARMS_ICO) {
write_atomic(&logo, NFARMS_ICO)?;
}
// 2. `[branding]` 追加进 custom.ini。已有的三段一个字都不动 —— 只往后接
let ini = grafana_ini_path(data);
let old = std::fs::read_to_string(&ini).map_err(|e| {
format!(
"读 {} 失败:{e} —— 先调 ensure_configs 生成 [server]/[security]/[analytics],\
再调 install_branding(它只往后追加第四段 [branding])",
ini.display()
)
})?;
if !has_branding_section(&old) {
let mut text = old;
// 上一行结尾没有换行就先补一个,否则 `[analytics]` 的最后一行会和
// `[branding]` 粘成 `check_for_plugin_updates = false[branding]`,
// 那一段直接作废(还会把上一个键的值污染成这一串)
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push_str(BRANDING_SECTION);
write_atomic(&ini, &text)?;
}
Ok(logo)
}
/// 缺哪个配置就生成哪个,**已存在的一律不覆盖**。
///
/// 「不覆盖」是硬要求而不是优化:`custom.ini` 和 `loki-config.yaml` 里除了
/// 端口,还有用户可能手改过的路径、密码、限流项。程序每次启动都按模板重写,
/// 等于**每次启动都把用户的修改抹掉**,而用户根本不知道是谁改的。
/// 生成的只是「没有时的那一份缺省」。
///
/// 返回 `Err` 的只有「该生成却写不下去」(目录不可写 / 磁盘满),
/// 而不是「已经存在」——后者是正常路径。
pub fn ensure_configs(data: &Path, loki_port: u16, grafana_port: u16) -> Result<(), String> {
let loki = loki_config_path(data);
if !loki.exists() {
write_atomic(&loki, loki_config_yaml(data, loki_port))?;
}
let grafana = grafana_ini_path(data);
if !grafana.exists() {
write_atomic(&grafana, grafana_ini(data, grafana_port))?;
}
Ok(())
}
// ---------------------------------------------------------------- 测试
#[cfg(test)]
mod tests {
use super::*;
/// 每个测试一个自己的临时目录,名字里带**进程号**:`cargo test` 默认多线程,
/// 同名目录会被别的测试删掉,表现成随机失败。临时目录用完不删 ——
/// 每次跑都是新名字,而删目录这一步本身也可能因为句柄没放干净而失败。
fn tmp_dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!(
"nfarms-observability-{}-{name}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// 造一个空文件当 exe(`spec` 只看 `is_file`,不执行它)。
fn touch(path: &Path) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, b"").unwrap();
}
/// 造一个「三件套都在」的 `libs\`。
///
/// **拿已经建好的根目录当参数**,而不是自己在里面再调一次 [`tmp_dir`]:
/// `tmp_dir` 会先删掉同名目录再重建,再调一次就把上一个变量指着的
/// `libs\` 连带删了 —— 症状是 `spec()` 莫名其妙返回 `None`。
fn libs_with_all_exes(root: &Path) -> PathBuf {
let libs = root.join("libs");
for s in ALL {
touch(&exe_path(s, &libs));
}
libs
}
// ------------------------------------------------------------ spec
#[test]
fn spec_is_none_instead_of_panicking_when_the_exe_is_missing() {
// 空目录 = 没装三件套。这里**不能** panic:界面在没有三件套的机器上
// 照样要能打开
let root = tmp_dir("spec-missing");
let libs = root.join("libs");
let data = root.join("data");
for s in ALL {
assert!(
spec(s, &libs, &data, default_port(s)).is_none(),
"{:?} 的 exe 不在,spec 必须返回 None",
s
);
}
}
#[test]
fn spec_args_carry_the_port_and_the_data_paths() {
let root = tmp_dir("spec-args");
let libs = libs_with_all_exes(&root);
let data = root.join("data");
let vm = spec(Service::Metrics, &libs, &data, 19091).unwrap();
assert_eq!(vm.name, "VictoriaMetrics");
assert_eq!(vm.exe, libs.join("victoria-metrics-windows-amd64-prod.exe"));
// 路径和端口都得进参数,少一个服务就起在别处/别的端口
assert_eq!(
vm.args,
vec![
"-storageDataPath".to_string(),
data.join("vm").display().to_string(),
"-httpListenAddr".to_string(),
":19091".to_string(),
]
);
assert_eq!(vm.port, 19091);
assert_eq!(vm.health_url, "http://127.0.0.1:19091/health");
let loki = spec(Service::Logs, &libs, &data, 19092).unwrap();
assert_eq!(loki.exe, libs.join("loki-windows-amd64.exe"));
assert_eq!(
loki.args,
vec![
"-config.file".to_string(),
data.join("loki").join("loki-config.yaml").display().to_string(),
]
);
assert_eq!(loki.health_url, "http://127.0.0.1:19092/ready");
let grafana = spec(Service::Dashboard, &libs, &data, 19093).unwrap();
assert_eq!(grafana.exe, libs.join("grafana").join("bin").join("grafana.exe"));
assert_eq!(
grafana.args,
vec![
"server".to_string(),
"--homepath".to_string(),
libs.join("grafana").display().to_string(),
"--config".to_string(),
data.join("grafana").join("custom.ini").display().to_string(),
]
);
assert_eq!(grafana.health_url, "http://127.0.0.1:19093/api/health");
}
/// 健康检查路径和就绪耗时是**实测出来的事实**,不是随手写的;
/// 谁把它改成 `core` / `ready`(Grafana 确实不叫 `/ready`)或者把 Loki
/// 的 17 秒改成 5,这条测试就红。
#[test]
fn health_paths_and_ready_hints_are_the_measured_ones() {
let root = tmp_dir("ready-hints");
let libs = libs_with_all_exes(&root);
let data = root.join("data");
let vm = spec(Service::Metrics, &libs, &data, 9091).unwrap();
let loki = spec(Service::Logs, &libs, &data, 9092).unwrap();
let grafana = spec(Service::Dashboard, &libs, &data, 9093).unwrap();
assert!(vm.health_url.ends_with("/health"));
assert!(loki.health_url.ends_with("/ready"));
assert!(grafana.health_url.ends_with("/api/health"));
assert_eq!((vm.ready_hint_secs, loki.ready_hint_secs, grafana.ready_hint_secs), (1, 17, 7));
}
#[test]
fn default_ports_are_the_three_measured_ones() {
assert_eq!(default_port(Service::Metrics), 9091);
assert_eq!(default_port(Service::Logs), 9092);
assert_eq!(default_port(Service::Dashboard), 9093);
}
#[test]
fn missing_names_exactly_the_services_whose_exe_is_absent() {
let root = tmp_dir("missing");
let libs = root.join("libs");
assert_eq!(
missing(&libs),
vec!["VictoriaMetrics", "Loki", "Grafana"],
"空目录下三件套都缺"
);
// 只补上 Loki,名单里就只剩另外两件
touch(&exe_path(Service::Logs, &libs));
assert_eq!(missing(&libs), vec!["VictoriaMetrics", "Grafana"]);
for s in ALL {
touch(&exe_path(s, &libs));
}
assert!(missing(&libs).is_empty(), "三件套齐了就不该再报缺");
}
/// exe 的**目录层级**也是实测事实,而上面那些测试是「自己造文件给自己看」——
/// `exe_path` 就算把 Grafana 写成 `libs\grafana.exe`(少了 `bin\` 那层),
/// 它们照样全绿(造文件时用的是同一个错规则)。这一条拿真的 `dist\libs\` 对,
/// 所以只在装好了三件套的开发机上跑得动;没装就跳过并打印一句,不静默。
#[test]
fn the_real_dist_layout_matches_where_this_module_looks_for_the_exes() {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let dist = manifest.join("..").join("..").join("dist");
let libs = dist.join("libs");
let data = dist.join("data");
let absent = missing(&libs);
if !absent.is_empty() {
eprintln!(
"跳过 {}:三件套没装齐(缺 {absent:?}),这一条只在装好的开发机上跑",
libs.display()
);
return;
}
for s in ALL {
let port = default_port(s);
let sp = spec(s, &libs, &data, port).unwrap_or_else(|| {
panic!("{s:?} 在真实的 libs 里找不到:路径规则和实际布局对不上")
});
assert!(sp.exe.is_file(), "{s:?} 的 exe 不是文件:{}", sp.exe.display());
assert!(sp.health_url.contains(&port.to_string()));
}
// 单独钉一下 Grafana:目录式分发,`--homepath` 指的是 exe 上面那两层
// (整个 `grafana\`),指到 `bin\` 里去它找不到 conf\ 和 public\
let g = spec(Service::Dashboard, &libs, &data, 9093).unwrap();
assert_eq!(g.args[0], "server");
assert_eq!(g.args[1], "--homepath");
assert_eq!(g.args[2], libs.join("grafana").display().to_string());
}
#[test]
fn start_reports_a_clear_error_when_the_exe_is_gone() {
let root = tmp_dir("start-missing");
let libs = root.join("libs");
let data = root.join("data");
// 拿一个手搓的 Spec 直接调:确认这条路径报错而不是 panic
let fake = Spec {
service: Service::Logs,
name: "Loki",
exe: libs.join("loki-windows-amd64.exe"),
args: vec![],
health_url: "http://127.0.0.1:9092/ready".to_string(),
port: 9092,
ready_hint_secs: 17,
};
let e = start(&fake).unwrap_err();
assert!(e.contains("Loki"), "报错要说清是哪一件:{e}");
// 顺带确认 spec 在同一个目录下也确实返回 None(同一份路径规则)
assert!(spec(Service::Logs, &libs, &data, 9092).is_none());
}
// ------------------------------------------------------------ 进程回收
/// 一个必定存在的长跑替身:`ping -n 30` ≈ 30 秒。
///
/// 不拿真的 Grafana / Loki:那两样不保证测试机上装了,而这条路径要证的
/// 只是"start() 起的进程进了 Job、关 Job 之后它会不会被内核收走"。
#[cfg(windows)]
fn long_running_stand_in() -> Option<PathBuf> {
let root = std::env::var("SystemRoot").ok()?;
let ping = PathBuf::from(root).join("System32").join("ping.exe");
ping.is_file().then_some(ping)
}
/// 三件服务各自的 Job 槽必须和 `ALL` 的顺序一致。
///
/// 这是纯函数那一半:错一位的后果是「停止 Grafana」去关了 Loki 的 Job
/// (或者谁都没关),而现象只是"进程还在",很难指回这张表。
#[test]
#[cfg(windows)]
fn job_slots_line_up_with_the_service_order() {
for (i, s) in ALL.iter().enumerate() {
assert_eq!(job_slot(*s), i, "{s:?} 的 Job 槽和 ALL 的顺序对不上");
}
}
/// 回归:**`start()` 起的子进程真的进了 Job,关掉 Job 它就得消失**。
///
/// 改之前 `Child::kill()` 之外什么都没有,Grafana 那 13 个插件后端会变成
/// 孤儿;这条测试盯的就是"关句柄 → 整棵树走"。
#[test]
#[cfg(windows)]
fn start_attaches_the_child_to_a_job_and_close_job_kills_the_tree() {
let Some(ping) = long_running_stand_in() else {
eprintln!("跳过:System32\\ping.exe 不在,拿不到长跑替身");
return;
};
let spec = Spec {
service: Service::Logs,
name: "Loki",
exe: ping,
args: vec!["-n".to_string(), "30".to_string(), "127.0.0.1".to_string()],
health_url: "http://127.0.0.1:9092/ready".to_string(),
port: 9092,
ready_hint_secs: 17,
};
let mut child = start(&spec).expect("替身进程没起来");
// 先确认它真被挂进去了 —— 没挂进去的话这条测试等于什么都没测
let job = job_handle(Service::Logs);
assert_ne!(job, 0, "start() 没给这一件建 Job");
assert!(
crate::job_win::is_member(job, &child),
"子进程没进 Job(AssignProcessToJobObject 失败)"
);
assert!(
child.try_wait().expect("try_wait 失败").is_none(),
"替身进程应该还在跑"
);
// 这一下就是 `obs_stop()` 要补的那一行
close_job(Service::Logs);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if child.try_wait().expect("try_wait 失败").is_some() {
return; // 内核把它收走了
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("关掉 Job 之后子进程还活着 —— 孤儿进程又回来了");
}
std::thread::sleep(Duration::from_millis(50));
}
}
/// Job 只建一次、复用;`close_job` 之后必须能**重新建一个**。
///
/// 不重建的话,下一次 `start()` 会拿着一个已经关掉的句柄去
/// `AssignProcessToJobObject` —— 那个失败是**静默**的,后果只剩
/// "没有回收",而"没有回收"正是这次要修的那个 bug。
#[test]
#[cfg(windows)]
fn a_service_keeps_one_job_and_gets_a_fresh_one_after_close() {
assert_eq!(job_handle(Service::Metrics), 0, "还没起进程就不该先建 Job");
let first = job_for(Service::Metrics);
assert_ne!(first, 0, "Job Object 没建出来");
assert_eq!(job_for(Service::Metrics), first, "第二次应该复用同一个 Job");
assert_eq!(
job_handle(Service::Metrics),
first,
"句柄要记在槽里,close_job 才关得掉"
);
close_job(Service::Metrics);
assert_eq!(job_handle(Service::Metrics), 0, "close_job 之后槽要清空");
let again = job_for(Service::Metrics);
assert_ne!(again, 0, "关掉之后必须能重新建一个(否则下一次 Assign 是静默失败)");
close_job(Service::Metrics);
}
/// 降级的那句话要**留得下、取得走、只取一次**。
///
/// 界面每帧都会调一次 `take_job_warning`,做成"读一下"的话同一句话会被
/// 写进日志几十遍。
#[test]
#[cfg(windows)]
fn job_warnings_are_recorded_and_taken_exactly_once() {
note_job_warning("测试用:回收降级了".to_string());
assert_eq!(
take_job_warning().as_deref(),
Some("测试用:回收降级了"),
"记下的话必须能取到"
);
assert!(take_job_warning().is_none(), "取过一次就该清掉");
}
// ------------------------------------------------------------ wait_ready
/// 找出一个**此刻没人监听**的端口:绑上再放掉。
///
/// 比手写一个 59999 更可靠 —— 那个数字可能正好被别的进程占着,于是
/// 「连接被拒」变成「连上了」,测试就成了偶发失败。
fn a_port_nobody_listens_on() -> u16 {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
drop(l);
port
}
fn probe_spec(name: &'static str, port: u16, path: &str) -> Spec {
Spec {
service: Service::Metrics,
name,
exe: PathBuf::from("NUL"),
args: vec![],
health_url: format!("http://127.0.0.1:{port}{path}"),
port,
// 0 秒实测耗时 → 预算 2 秒(见 ready_budget),测试不用干等
ready_hint_secs: 0,
}
}
#[test]
fn wait_ready_gives_up_on_a_port_nobody_listens_on() {
let port = a_port_nobody_listens_on();
let s = probe_spec("假服务", port, "/health");
let started = Instant::now();
let e = wait_ready_with(&s, Duration::from_millis(700)).unwrap_err();
// 超时返回 Err —— 而不是挂死。给个宽松上限:真正要防的是「永不返回」
assert!(started.elapsed() < Duration::from_secs(30), "必须自己超时返回");
assert!(e.contains("假服务"), "报错要说清是哪一件:{e}");
assert!(e.contains("还没就绪"), "报错要说清是超时:{e}");
}
/// 「端口连得上、但一个字都不回」—— 这就是 `weather.rs` 卡死的形态。
/// 监听端**一直不放**:`connect` 会成功,只有请求级超时能把它兜住。
#[test]
fn wait_ready_gives_up_when_the_port_accepts_but_never_answers() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let s = probe_spec("假服务", port, "/health");
let started = Instant::now();
let e = wait_ready_with(&s, Duration::from_millis(700)).unwrap_err();
assert!(started.elapsed() < Duration::from_secs(30), "必须自己超时返回");
assert!(e.contains("还没就绪"), "报错要说清是超时:{e}");
// 监听端在本函数结束时才析构:整个过程中端口都有人听着
drop(listener);
}
/// Loki 实测 17 秒 —— 预算必须**明显**大于它,否则每次都被判成起不来。
#[test]
fn loki_budget_has_room_over_the_measured_seventeen_seconds() {
let loki = Spec {
service: Service::Logs,
name: "Loki",
exe: PathBuf::from("NUL"),
args: vec![],
health_url: "http://127.0.0.1:9092/ready".to_string(),
port: 9092,
ready_hint_secs: 17,
};
let budget = ready_budget(&loki);
assert!(
budget >= Duration::from_secs(17),
"预算 {budget:?} 比实测就绪耗时就短,等于每次都判失败"
);
assert!(
budget >= Duration::from_secs(34),
"只留和实测一样长的预算等于没有余量,机器慢一点就误判"
);
}
// ------------------------------------------------------------ 推数据
/// 只应答一次请求的假服务端:返回 (端口, 收到的那份请求文本)。
///
/// 用它而不是真的起 Loki/VM:这里要验的是「路径对不对、body 是什么」,
/// 那是**我们自己的事**;真去起服务,测试就变成了「这台机器上装没装
/// 三件套」的检查。
fn one_shot_server() -> (u16, std::sync::mpsc::Receiver<String>) {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let Ok((mut sock, _)) = listener.accept() else {
return;
};
// 读超时只是「请求就这么多」的结束条件,不是网络故障
sock.set_read_timeout(Some(Duration::from_millis(1500)))
.unwrap();
let mut buf = Vec::new();
let mut chunk = [0u8; 1024];
loop {
match sock.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
let text = String::from_utf8_lossy(&buf).to_string();
if let Some(head_end) = text.find("\r\n\r\n") {
// 头部按 Content-Length 算准,别靠「读超时」猜 ——
// 猜早了会把 body 截断,断出来的 JSON 直接解析失败
let want = content_length(&text[..head_end]).unwrap_or(0);
if buf.len() >= head_end + 4 + want {
break;
}
}
}
Err(_) => break,
}
}
let _ = sock.write_all(
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
);
let _ = tx.send(String::from_utf8_lossy(&buf).to_string());
});
(port, rx)
}
fn content_length(head: &str) -> Option<usize> {
head.lines()
.find(|l| l.to_ascii_lowercase().starts_with("content-length:"))
.and_then(|l| l.split(':').nth(1))
.and_then(|v| v.trim().parse().ok())
}
fn body_of(request: &str) -> &str {
request.split_once("\r\n\r\n").map(|(_, b)| b).unwrap_or("")
}
#[test]
fn push_metric_posts_the_line_itself_to_the_import_endpoint() {
let (port, rx) = one_shot_server();
push_metric(port, "nfarms_probe_seconds 1").unwrap();
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(
req.starts_with("POST /api/v1/import/prometheus "),
"路径必须是被实测过的那个:{req}"
);
assert!(
req.to_ascii_lowercase().contains("content-type: text/plain"),
"VM 的 import 接口收的是裸文本:{req}"
);
// body **就是那一行**,不加换行、不包 JSON
assert_eq!(body_of(&req), "nfarms_probe_seconds 1");
}
#[test]
fn push_log_posts_the_loki_json_with_a_nanosecond_string_timestamp() {
let (port, rx) = one_shot_server();
push_log(port, &[("app", "nfarms"), ("level", "info")], "启动完成")
.unwrap();
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(
req.starts_with("POST /loki/api/v1/push "),
"路径必须是 /loki/api/v1/push:{req}"
);
assert!(req.to_ascii_lowercase().contains("content-type: application/json"));
let v: serde_json::Value = serde_json::from_str(body_of(&req)).unwrap();
let stream = &v["streams"][0];
assert_eq!(stream["stream"]["app"], "nfarms");
assert_eq!(stream["stream"]["level"], "info");
let values = &stream["values"][0];
// 时间戳必须是**字符串**形式的纳秒:JSON 数字会掉精度
let ts = values[0].as_str().expect("纳秒时间戳必须是字符串");
assert!(ts.len() >= 19, "纳秒时间戳至少 19 位,拿到的是 {ts}");
assert!(ts.chars().all(|c| c.is_ascii_digit()));
assert_eq!(values[1], "启动完成");
}
#[test]
fn push_log_refuses_an_empty_label_set_before_hitting_the_network() {
// 端口故意给一个连不上的值:空标签集要在**发请求之前**就被挡下,
// 报的才是「至少一个标签」而不是「连不上」
let e = push_log(1, &[], "x").unwrap_err();
assert!(e.contains("标签"), "报错要指向真正的原因:{e}");
}
#[test]
fn log_payload_keeps_multi_line_text_and_label_values_verbatim() {
let body = log_payload(
&[("msg", "带\"引号\"的值")],
"第一行\n第二行",
"1700000000000000000",
);
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
let values = &v["streams"][0]["values"][0];
assert_eq!(values[1], "第一行\n第二行", "换行不能被转义成两行");
assert_eq!(v["streams"][0]["stream"]["msg"], "带\"引号\"的值");
}
// ------------------------------------------------------------ 数据源
#[test]
fn provision_datasources_writes_both_and_is_idempotent() {
let data = tmp_dir("datasources").join("data");
let path = provision_datasources(&data, 19091, 19092).unwrap();
assert_eq!(
path,
data.join("grafana")
.join("provisioning")
.join("datasources")
.join("nfarms.yml")
);
let first = std::fs::read_to_string(&path).unwrap();
for needle in [
"uid: nfarms-metrics",
"uid: nfarms-logs",
"url: http://127.0.0.1:19091",
"url: http://127.0.0.1:19092",
"type: prometheus",
"type: loki",
] {
assert!(first.contains(needle), "数据源文件里少了 {needle}:\n{first}");
}
// 端口要用传进来的那个,不是默认值。**比完整的 url 行**,不能只比 "9091"
// —— "19091" 里就含 "9091",那样比等于没比
assert!(!first.contains("http://127.0.0.1:9091"), "端口要用传进来的那个");
assert!(!first.contains("http://127.0.0.1:9092"), "端口要用传进来的那个");
// 再调一次:内容必须一模一样(幂等),临时文件也不能留下
let again = provision_datasources(&data, 19091, 19092).unwrap();
assert_eq!(again, path);
assert_eq!(std::fs::read_to_string(&path).unwrap(), first);
assert!(
!tmp_sibling(&path).exists(),
"落盘走 .tmp + rename,成功之后不能留下 .tmp"
);
}
#[test]
fn provision_datasources_follows_the_ports_it_is_given() {
let data = tmp_dir("datasources-regen").join("data");
let path = provision_datasources(&data, 19091, 19092).unwrap();
// 端口变了 → 内容跟着变(不然改端口就得手删文件)
provision_datasources(&data, 29091, 29092).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("url: http://127.0.0.1:29091"));
assert!(!text.contains("19091"));
}
// ------------------------------------------------------------ 看板
/// 看板的两个文件都写出来、路径对、字段名对,而且**幂等**(内容一样不重写)。
#[test]
fn provision_dashboards_writes_provider_and_dashboard_and_is_idempotent() {
let data = tmp_dir("dashboards").join("data");
let json_path = provision_dashboards(&data).unwrap();
let provider_path = dashboards_provider_path(&data);
assert_eq!(
provider_path,
data.join("grafana")
.join("provisioning")
.join("dashboards")
.join("nfarms.yml")
);
assert_eq!(
json_path,
data.join("grafana").join("dashboards").join("nfarms.json")
);
// 返回的是**看板本体**(provider 那个是纯注册文件,路径可推导)
assert_eq!(json_path, dashboard_json_path(&data));
let yml = std::fs::read_to_string(&provider_path).unwrap();
// provider 的字段名是 Grafana 13 那一套,少一个/拼错一个它就不装这个 provider
// (而且不报错,只是看板列表里空着 —— 所以这几行逐个钉住)
for needle in [
"apiVersion: 1",
"providers:",
"name: nfarms",
"folder: nfarms",
"type: file",
"disableDeletion: false",
"updateIntervalSeconds: 30",
"allowUiUpdates: true",
] {
assert!(yml.contains(needle), "provider 配置里少了 `{needle}`:\n{yml}");
}
// path 指向 data\grafana\dashboards(不是 provisioning 那层),且是正斜杠
let want = format!(
"path: {}",
yaml_path(&data.join("grafana").join("dashboards"))
);
assert!(yml.contains(&want), "provider 的 path 不对,期望 `{want}`:\n{yml}");
assert!(!yml.contains('\\'), "YAML 里不能出现反斜杠路径:\n{yml}");
assert!(
!yml.contains("provisioning/dashboards"),
"path 指的是看板目录,不是 provider 自己所在的那层:\n{yml}"
);
// 再调一次:两个文件都必须一个字节都不变,临时文件也不能留下
let before_json = std::fs::read_to_string(&json_path).unwrap();
let before_yml = yml.clone();
let again = provision_dashboards(&data).unwrap();
assert_eq!(again, json_path);
assert_eq!(std::fs::read_to_string(&json_path).unwrap(), before_json);
assert_eq!(std::fs::read_to_string(&provider_path).unwrap(), before_yml);
assert!(!tmp_sibling(&json_path).exists(), "不能留下 .tmp");
assert!(!tmp_sibling(&provider_path).exists(), "不能留下 .tmp");
}
/// 看板本体要能被 `serde_json` 解回来,而且**里面引用的是真的存在的东西**:
/// 数据源的 uid、真推了的指标、互不重叠的面板坐标。
#[test]
fn the_dashboard_json_is_a_grafana_dashboard_that_only_queries_metrics_we_push() {
let data = tmp_dir("dashboard-json").join("data");
let path = provision_dashboards(&data).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
let v: serde_json::Value = serde_json::from_str(&text).expect("看板必须是合法 JSON");
assert_eq!(v["uid"], "nfarms-overview");
assert_eq!(v["title"], "NFarms HUB");
assert_eq!(v["version"], 1);
assert!(v["schemaVersion"].is_i64(), "缺 schemaVersion,Grafana 会当成很老的版本去迁移");
// 默认时间范围 = 最近 6 小时
assert_eq!(v["time"]["from"], "now-6h");
assert_eq!(v["time"]["to"], "now");
let panels = v["panels"].as_array().expect("panels 必须是数组");
assert!(panels.len() >= 3, "至少要有 累计 / 速率 / 输入输出 三类面板");
// 数据源按 **uid** 关联:写名字或者留空,导入后是一整页 "Datasource not found"
let metrics_ds = serde_json::json!({"type": "prometheus", "uid": "nfarms-metrics"});
let mut rects: Vec<(i64, i64, i64, i64, String)> = Vec::new();
let mut exprs: Vec<String> = Vec::new();
let mut panels_with_two_series = 0;
for p in panels {
let title = p["title"].as_str().unwrap_or("<没有标题>").to_string();
assert_eq!(p["datasource"], metrics_ds, "panel {title} 的 datasource 不对");
// 类型拼错(Grafana 对不认识的类型是**静默不画**)在这里挡下
let kind = p["type"].as_str().unwrap_or("");
assert!(
kind == "stat" || kind == "timeseries",
"panel {title} 的类型 {kind} 不是这两个之一"
);
let g = &p["gridPos"];
let (x, y, w, h) = (
g["x"].as_i64().unwrap(),
g["y"].as_i64().unwrap(),
g["w"].as_i64().unwrap(),
g["h"].as_i64().unwrap(),
);
assert!(w > 0 && h > 0 && x >= 0 && y >= 0, "panel {title} 的坐标/尺寸不合理");
assert!(x + w <= 24, "panel {title} 超出 24 列网格");
rects.push((x, y, w, h, title.clone()));
let targets = p["targets"].as_array().expect("targets 必须是数组");
assert!(!targets.is_empty(), "panel {title} 一条查询都没有");
if targets.len() >= 2 {
panels_with_two_series += 1;
}
for t in targets {
let expr = t["expr"].as_str().expect("expr 必须是字符串");
assert!(!expr.trim().is_empty(), "panel {title} 里有空表达式");
assert_eq!(
t["datasource"], metrics_ds,
"panel {title} 的 target 没带对数据源"
);
exprs.push(expr.to_string());
}
}
// 面板坐标**不能重叠**:重叠的两个会互相盖住,看着像少画了一个
for (i, a) in rects.iter().enumerate() {
for b in &rects[i + 1..] {
let hit = a.0 < b.0 + b.2 && b.0 < a.0 + a.2 && a.1 < b.1 + b.3 && b.1 < a.1 + a.3;
assert!(!hit, "panel {} 和 {} 的 gridPos 重叠了", a.4, b.4);
}
}
// 1) 累计:一个面板里按 kind 分两条
assert!(
panels_with_two_series >= 1,
"「Token 累计」那张要按 kind 出两条,现在没有两系列的面板"
);
// 2) 增量速率:必须真有 rate/increase,而不是又画一遍累计
assert!(
exprs.iter().any(|e| e.contains("rate(") || e.contains("increase(")),
"少了增量速率那条(rate/increase):{exprs:?}"
);
// 3) 输入 / 输出 各一条
assert!(
exprs.iter().any(|e| e.contains("kind=\"prompt\"")),
"少了输入 token 那条:{exprs:?}"
);
assert!(
exprs.iter().any(|e| e.contains("kind=\"completion\"")),
"少了输出 token 那条:{exprs:?}"
);
// 查的必须是**真的推了的**那条指标
for e in &exprs {
assert!(
e.contains("nfarms_tokens_total"),
"查询里出现了程序没推过的指标:{e}"
);
}
// 不存在系统指标 → 一个 panel 都不许画(空 panel 会让人以为坏了)
for ghost in ["nfarms_cpu", "nfarms_mem", "nfarms_memory", "nfarms_disk"] {
assert!(
!text.contains(ghost),
"看板里画了程序根本没推的指标 `{ghost}`"
);
}
}
// ------------------------------------------------------------ 配置生成
#[test]
fn ensure_configs_generates_missing_files_and_never_overwrites_existing_ones() {
let data = tmp_dir("ensure").join("data");
ensure_configs(&data, 19092, 19093).unwrap();
let loki = loki_config_path(&data);
let ini = grafana_ini_path(&data);
let loki_text = std::fs::read_to_string(&loki).unwrap();
let ini_text = std::fs::read_to_string(&ini).unwrap();
// 端口和路径都落到文件里了;YAML 里的 Windows 路径是正斜杠
assert!(loki_text.contains("http_listen_port: 19092"), "{loki_text}");
assert!(
loki_text.contains(&yaml_path(&data.join("loki"))),
"path_prefix 要用正斜杠的绝对路径:\n{loki_text}"
);
assert!(
!loki_text.contains(&data.join("loki").display().to_string()),
"YAML 里不能出现反斜杠路径:\n{loki_text}"
);
assert!(ini_text.contains("http_port = 19093"), "{ini_text}");
assert!(
ini_text.contains(&data.join("grafana").display().to_string()),
"ini 里的路径按现有那份用反斜杠:\n{ini_text}"
);
// 把两份都改成「用户手改过的样子」,再跑一遍:一个字都不能被动
std::fs::write(&loki, "手改过的 loki 配置").unwrap();
std::fs::write(&ini, "手改过的 grafana 配置").unwrap();
ensure_configs(&data, 19092, 19093).unwrap();
assert_eq!(std::fs::read_to_string(&loki).unwrap(), "手改过的 loki 配置");
assert_eq!(
std::fs::read_to_string(&ini).unwrap(),
"手改过的 grafana 配置"
);
assert!(!tmp_sibling(&loki).exists(), "不能留下 .tmp");
assert!(!tmp_sibling(&ini).exists(), "不能留下 .tmp");
}
#[test]
fn generated_configs_are_byte_identical_to_the_shipped_ones() {
// 这条断言直接拿 `dist\data\` 里**已经实测跑通**的那两份比字节。
// 它只在开发机上跑得动(那两份配置不属于源码,别的机器/CI 上可能没有),
// 所以没有就跳过并打印一句 —— 但不静默:跳过要说出来。
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let dist_data = manifest.join("..").join("..").join("dist").join("data");
let shipped_loki_path = dist_data.join("loki").join("loki-config.yaml");
let shipped_ini_path = dist_data.join("grafana").join("custom.ini");
if !shipped_loki_path.is_file() || !shipped_ini_path.is_file() {
eprintln!(
"跳过 {}:没有现成的配置可对照(这个断言只在开发机上跑)",
dist_data.display()
);
return;
}
let shipped_loki = std::fs::read_to_string(&shipped_loki_path).unwrap();
let shipped_ini = std::fs::read_to_string(&shipped_ini_path).unwrap();
// 先确认这两份真的读到了东西:两边都是空串时 `assert_eq!` 也会「通过」,
// 那这条测试就成了摆设
assert!(
shipped_loki.len() > 400 && shipped_ini.len() > 400,
"读到的现有配置太短,比对已失效(loki {} 字节,ini {} 字节)",
shipped_loki.len(),
shipped_ini.len()
);
// 基准目录从**现有文件里抠出来**:不用去猜开发机上的绝对路径,
// 也不能用 canonicalize —— 它在 Windows 上会加上 `\\?\` 前缀,
// 生成出来的字符串反而和现有那份对不上
let base_of_loki = shipped_base(&shipped_loki, "path_prefix:", '\'');
let base_of_ini = shipped_base(&shipped_ini, "data =", '=');
assert_eq!(
loki_config_yaml(&base_of_loki, 9092),
shipped_loki,
"生成的 loki-config.yaml 必须和 dist 里那份逐字节一致(注释、端口、引号都算)"
);
// **只比到 `[branding]` 之前。**
//
// 那一段是 `install_branding` **运行时追加**的第四段,不在 `grafana_ini`
// 的模板里(模板只生成前三段)。而 `ensure_configs` 不覆盖已存在的文件,
// 所以 dist 里那份一旦被 install 过就**永久**多出这一段 ——
// 拿它和模板逐字节比会红,而红的原因不是"模板错了"。
//
// 直接改成「不比那一段」而不是「让模板也生成它」:品牌化是**运行时**动作
// (用户可能永远不启 Grafana),模板里塞一段只在一部分机器上出现的配置,
// 会让 `ensure_configs` 的语义变模糊。
let shipped_ini_body = shipped_ini
.split("\n[branding]")
.next()
.unwrap_or(&shipped_ini)
.trim_end();
assert_eq!(
grafana_ini(&base_of_ini, 9093),
shipped_ini_body,
"生成的 custom.ini 前三段必须和 dist 里那份逐字节一致"
);
}
/// 从现有配置里抠出「基准目录」:loki 的 `path_prefix: '<base>/loki'`、
/// grafana 的 `data = <base>\grafana`,取它们的父目录。
fn shipped_base(text: &str, needle: &str, sep: char) -> PathBuf {
let line = text
.lines()
.find(|l| l.contains(needle))
.unwrap_or_else(|| panic!("现有配置里找不到 {needle} 那行"));
let value = line
.split(sep)
.nth(1)
.unwrap_or_else(|| panic!("{needle} 那行抠不出路径:{line}"))
.trim()
.trim_matches('\'');
PathBuf::from(value)
.parent()
.unwrap_or_else(|| panic!("{value} 没有父目录"))
.to_path_buf()
}
// ------------------------------------------------------------ 品牌 logo
/// logo 装到位、`[branding]` 接在三段后面,而且**调两次不会追加两遍**。
#[test]
fn install_branding_writes_the_logo_and_appends_the_section_exactly_once() {
let root = tmp_dir("branding");
let libs = root.join("libs");
let data = root.join("data");
// custom.ini 先由 ensure_configs 生成(install_branding 只追加,不自己造)
ensure_configs(&data, 19092, 19093).unwrap();
let ini = grafana_ini_path(&data);
let three_sections = std::fs::read_to_string(&ini).unwrap();
let logo = install_branding(&libs, &data).unwrap();
assert_eq!(
logo,
libs.join("grafana")
.join("public")
.join("img")
.join("nfarms-logo.ico")
);
assert_eq!(logo, branding_logo_path(&libs));
// 落盘位置和配置里那个相对路径必须是同一个地方,否则就是一张破图
assert!(
logo.ends_with(Path::new(LOGO_REL_PATH)),
"logo 落在 {} ,而 [branding] 指的是 {LOGO_REL_PATH}",
logo.display()
);
assert!(BRANDING_SECTION.contains(LOGO_REL_PATH));
// 必须是**逐字节**的应用图标:不是转码、不是裁剪过的另一份
let bytes = std::fs::read(&logo).unwrap();
assert_eq!(bytes, NFARMS_ICO, "写出来的不是 assets\\nfarms.ico 的字节");
assert!(bytes.len() > 1024, "图标不该是个空壳:{} 字节", bytes.len());
assert!(
bytes.starts_with(&[0x00, 0x00, 0x01, 0x00]),
"写出去的必须还是那个 ICO 文件(前 4 字节是 ICO 的魔数)"
);
assert!(!tmp_sibling(&logo).exists(), "不能留下 .tmp");
let text = std::fs::read_to_string(&ini).unwrap();
// 已有的三段一个字都没动:新内容只是接在**原文后面**
assert!(text.starts_with(&three_sections), "已有的三段被改动了:\n{text}");
assert_eq!(
&text[..three_sections.len()],
three_sections.as_str(),
"custom.ini 前面那段必须原样保留"
);
for needle in [
"[branding]",
"app_title = NFarms HUB",
"login_logo = public/img/nfarms-logo.ico",
"menu_logo = public/img/nfarms-logo.ico",
"favicon = public/img/nfarms-logo.ico",
] {
assert!(text.contains(needle), "custom.ini 里少了 `{needle}`:\n{text}");
}
// 上一段的最后一行不能被粘住:少一个换行会让 `[analytics]` 那个键的值
// 变成 `false[branding]`,于是整段配置作废
assert!(text.contains("\n[branding]"), "段名前面必须有换行:\n{text}");
assert!(!text.contains("false[branding]"), "上一段被粘住了:\n{text}");
// 再装一次:内容必须一模一样(幂等),而且只有一段 [branding]
let before = text.clone();
let again = install_branding(&libs, &data).unwrap();
assert_eq!(again, logo);
assert_eq!(
std::fs::read_to_string(&ini).unwrap(),
before,
"第二次 install 改了 custom.ini"
);
assert_eq!(before.matches("[branding]").count(), 1, "追加了两遍:\n{before}");
assert_eq!(std::fs::read(&logo).unwrap(), NFARMS_ICO);
assert!(!tmp_sibling(&ini).exists(), "不能留下 .tmp");
}
/// `custom.ini` 不在时**报错,而不是自己造一份**:造出来的那份没有
/// `[server] http_port`,Grafana 会退回默认的 3000,而健康检查还在打 9093 ——
/// 症状是「Grafana 永远起不来」,原因却是一份缺了半段的配置。
#[test]
fn install_branding_refuses_without_a_custom_ini_instead_of_writing_a_half_config() {
let root = tmp_dir("branding-no-ini");
let libs = root.join("libs");
let data = root.join("data");
let e = install_branding(&libs, &data).unwrap_err();
assert!(e.contains("ensure_configs"), "报错要指向该先调谁:{e}");
assert!(
!grafana_ini_path(&data).exists(),
"宁可报错也不能生成一份缺 [server] 的 custom.ini"
);
// logo 那一步不依赖 ini,已经落好了 —— 这是有意的:补上 ini 再跑一次即可
assert_eq!(std::fs::read(branding_logo_path(&libs)).unwrap(), NFARMS_ICO);
}
/// 用户手写的 `[branding]` 不能被动:两个同名段里后一个会盖掉前一个,
/// 而用户的标题/图是用户自己的事(和 ensure_configs 不覆盖已有配置同一立场)。
#[test]
fn install_branding_keeps_a_hand_written_branding_section() {
let root = tmp_dir("branding-user");
let libs = root.join("libs");
let data = root.join("data");
ensure_configs(&data, 19092, 19093).unwrap();
let ini = grafana_ini_path(&data);
let mine = format!(
"{}\n[branding]\napp_title = 我自己起的名字\n",
std::fs::read_to_string(&ini).unwrap()
);
std::fs::write(&ini, &mine).unwrap();
install_branding(&libs, &data).unwrap();
assert_eq!(
std::fs::read_to_string(&ini).unwrap(),
mine,
"用户写的 [branding] 被覆盖/重复追加了"
);
assert_eq!(mine.matches("[branding]").count(), 1);
}
/// 「已经有了」的判定:段名大小写不敏感、前后空白不算,但**注释掉的段名不算**
/// (否则被注释掉的那一行会让这一段永远装不上,而用户看不到任何报错)。
#[test]
fn branding_section_detection_ignores_comments_and_case() {
assert!(has_branding_section("[server]\nhttp_port = 9093\n\n[BRANDING]\nx = y\n"));
assert!(has_branding_section(" [branding] \n"));
assert!(!has_branding_section("[server]\nhttp_port = 9093\n"));
assert!(
!has_branding_section("[analytics]\n; [branding]\ncheck_for_updates = false\n"),
"注释里的 [branding] 不算数"
);
assert!(
!has_branding_section("[analytics]\n# [branding]\n"),
"`#` 注释也不算数"
);
}
}