=== 设计边界(刻意不做"编辑完整 promtail.yaml")===
开放的是**采集项本身**的 YAML 表示(字段与 models.ScrapeJob 一一对应):
name / paths / labels / encoding / json / system
multiline(firstline,maxLines) / timestamp / dropIfMatch / rateLimit
clients、positions、server、scrape_configs 外层仍由平台生成。
理由(真实故障面):
用户删掉 positions → 重启后从头重采,大量重复 + Loki 以
entry too far behind 拒绝整批;删掉 clients → 完全不外发。
这两类问题在页面上看不出来,只能登机器看日志,排障成本极高。
只开放采集规则,写坏了最多某条日志没采到,不会让采集器整体失效。
=== 后端 ===
新增 services/collector_jobs_yaml.go:
RenderJobsYAML(jobs) → 带边界说明注释的 YAML(明确写"clients/positions 不在此修改")
ParseJobsYAML(text) → 复用 models.ValidateScrapeJobs,与表单完全同一套校验
错误信息必须具体:
YAML 语法错 → 「YAML 语法错误:yaml: line 1: did not find expected ',' or ']'」
语义错 → 「采集项「app」至少需要填写一个日志路径」
新增路由(用 /collector-jobs/* 前缀,避开与 /collectors/:id 的 gin 通配冲突):
POST /api/v1/collector-jobs/render
POST /api/v1/collector-jobs/parse
=== 前端 ===
编辑弹框(主机模式)加「表单 / YAML」切换:
- 切到 YAML:调 render 把当前采集项渲染出来
- 切回表单:调 parse 回填;解析失败**留在 YAML 模式**并显示原因
(绝不能悄悄丢掉用户改动)
- 停留在 YAML 模式点保存:先 parse,失败则中止保存
- 编辑区 min-h 固定、等宽字体、可拖拽高度
=== 测试 ===
TestRenderParseJobsYAMLRoundTrip:
表单→YAML→表单 无损往返,逐字段核对
(multiline.maxLines / rateLimit / dropIfMatch / timestamp / labels / system)
TestParseJobsYAMLErrors:空内容、语法错、空列表、缺名称、缺路径、名称非法
→ 6 种输入都给出具体错误,不是笼统"格式错误"
=== 实测(c11d2badce64)===
render: code=0,17 行,含边界说明
parse : code=0,1 个采集项;往返校验 name=app paths=1 multiline=True
rateLimit={rate:2000,burst:4000} drop=1 json=True
错误: 「采集项「app」至少需要填写一个日志路径」
「YAML 语法错误:yaml: line 1: did not find expected ',' or ']'」
Pod = Hub latest = c11d2badce64
112 lines
3.4 KiB
Go
112 lines
3.4 KiB
Go
package services
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
lokimodels "lokistack/models"
|
|
)
|
|
|
|
// YAML 双模式的核心保证:**表单 → YAML → 表单 必须无损往返**。
|
|
// 如果往返丢字段(比如 multiline.maxLines 或 rateLimit),用户切一下模式
|
|
// 配置就被悄悄改掉了,这是最难排查的一类问题。
|
|
func TestRenderParseJobsYAMLRoundTrip(t *testing.T) {
|
|
original := []lokimodels.ScrapeJob{
|
|
{
|
|
Name: "app",
|
|
Paths: []string{"/var/log/app/*.log", "/data/logs/*.log"},
|
|
Labels: map[string]string{
|
|
"tier": "backend",
|
|
},
|
|
Encoding: "utf-8",
|
|
JSON: true,
|
|
Multiline: &lokimodels.JobMultiline{
|
|
FirstLine: `^\d{4}-\d{2}-\d{2}`,
|
|
MaxLines: 256,
|
|
},
|
|
DropIfMatch: []string{`/healthz`, `/metrics`},
|
|
RateLimit: &lokimodels.JobRateLimit{Rate: 2000, Burst: 4000},
|
|
Timestamp: &lokimodels.JobTimestamp{
|
|
Source: "ts",
|
|
Format: "RFC3339Nano",
|
|
Location: "Local",
|
|
},
|
|
},
|
|
{
|
|
Name: "system",
|
|
Paths: []string{"/var/log/messages"},
|
|
System: true,
|
|
},
|
|
}
|
|
|
|
text, err := RenderJobsYAML(original)
|
|
if err != nil {
|
|
t.Fatalf("渲染失败: %v", err)
|
|
}
|
|
// 头部注释必须说明"这不是完整 promtail 配置",避免用户以为能改 clients
|
|
if !strings.Contains(text, "clients") || !strings.Contains(text, "不在此处修改") {
|
|
t.Error("渲染结果缺少边界说明注释")
|
|
}
|
|
|
|
parsed, err := ParseJobsYAML(text)
|
|
if err != nil {
|
|
t.Fatalf("解析失败: %v\n--- YAML ---\n%s", err, text)
|
|
}
|
|
if len(parsed) != len(original) {
|
|
t.Fatalf("采集项数量不符: got %d want %d", len(parsed), len(original))
|
|
}
|
|
|
|
first := parsed[0]
|
|
if first.Name != "app" || len(first.Paths) != 2 {
|
|
t.Errorf("基础字段丢失: %+v", first)
|
|
}
|
|
if first.Multiline == nil || first.Multiline.MaxLines != 256 {
|
|
t.Errorf("multiline 丢失: %+v", first.Multiline)
|
|
}
|
|
if first.RateLimit == nil || first.RateLimit.Rate != 2000 || first.RateLimit.Burst != 4000 {
|
|
t.Errorf("rateLimit 丢失: %+v", first.RateLimit)
|
|
}
|
|
if len(first.DropIfMatch) != 2 {
|
|
t.Errorf("dropIfMatch 丢失: %+v", first.DropIfMatch)
|
|
}
|
|
if !first.JSON || first.Encoding != "utf-8" {
|
|
t.Errorf("json/encoding 丢失: json=%v enc=%q", first.JSON, first.Encoding)
|
|
}
|
|
if first.Timestamp == nil || first.Timestamp.Source != "ts" || first.Timestamp.Location != "Local" {
|
|
t.Errorf("timestamp 丢失: %+v", first.Timestamp)
|
|
}
|
|
if first.Labels["tier"] != "backend" {
|
|
t.Errorf("labels 丢失: %+v", first.Labels)
|
|
}
|
|
if !parsed[1].System {
|
|
t.Error("system 标记丢失")
|
|
}
|
|
}
|
|
|
|
// 报错必须具体:语法错要能定位,语义错要指出是哪个采集项,不能笼统"格式错误"。
|
|
func TestParseJobsYAMLErrors(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{"空内容", " ", "YAML 为空"},
|
|
{"语法错误", "- name: app\n paths: [/a\n", "YAML 语法错误"},
|
|
{"空列表", "[]", "没有采集项"},
|
|
{"缺名称", "- paths: [/var/log/x.log]", "缺少名称"},
|
|
{"缺路径", "- name: app", "至少需要填写一个日志路径"},
|
|
{"名称非法", "- name: 'bad name!'\n paths: [/a]", "不合法"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
_, err := ParseJobsYAML(c.in)
|
|
if err == nil {
|
|
t.Fatalf("期望报错,实际通过")
|
|
}
|
|
if !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("错误信息不够具体: got %q, want contains %q", err.Error(), c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|