feat(collectors): 可编辑采集配置(B 档结构化编辑器)+ 修 host 标签 / syslog 时间戳

=== 一、两个线上 bug(172.19.16.8 真机取证)===

1) host 标签是字面量 ${HOSTNAME}
   Promtail 不做环境变量展开(除非 -config.expand-env + systemd Environment),
   实测 21,825 条日志的 host 全是 "${HOSTNAME}",无法按主机过滤。
   → 安装时 `hostname -s` 探测真实主机名并渲染进配置,结果存 Collector.ResolvedHostname,
     让「预览配置」与线上一致。

2) 系统日志时间戳解析失效
   TencentOS 4 的 /var/log/messages 是 ISO8601(2026-07-17T15:38:24.923784+08:00),
   旧正则按传统 syslog(Oct 28 18:11:06)写死 → timestamp 阶段匹配不上,用的是采集时间,
   回填历史日志时间全错。
   → 安装时探测目标机格式:iso8601 → RFC3339Nano;rfc3164 → Jan _2 15:04:05 + Local;
     系统日志路径同时探测(Ubuntu 没有 messages,写死会一条都采不到)。

   实测:写一条 10 分钟前的 ISO8601 探针行,Loki 里 loki_ts 与该行自身时间偏差 **0 秒**;
   另写一条 2 小时前的被 Loki 以 entry too far behind 拒绝 —— 恰好反证时间戳是按事件时间
   解析后推送的(旧实现会用采集时间,反而被接收)。

=== 二、B 档:结构化多 Job 编辑器 ===

后端
- models/scrape_job.go:ScrapeJob + ValidateScrapeJobs
  校验 job 名格式、绝对路径、**敏感路径拦截**(/etc/shadow、/etc/sudoers、/root/.ssh、
  id_rsa、*.pem…)、正则可编译、job/host/__path__ 保留标签名、数量上限。
- services/promtail_host_config.go:多 job 渲染;多路径展开成多个 static_configs;
  pipeline 顺序硬约束 cri → multiline → json → timestamp → drop → limit;
  encoding 写在 scrape_config 级(放进 static_configs 里 promtail 直接起不来)。
- services/host_profile.go:探测 distro / init / hostname / 时区 / 架构 / journald
  与真实存在的系统日志路径。
- CollectorService:renderConfig 实时渲染、PreviewConfig(只渲染不落库)、
  Update 支持 scrapeJobs 并校验、安装结果(hostname/syslog 格式/配置快照)落库。
- 新增 POST /api/v1/collectors/:id/preview → {config, warnings, hostname, syslogFormat}。
- installHost:配置先写 .new + `promtail -check-syntax` 通过才替换,重启后轮询
  systemctl is-active,失败把 journalctl 尾部带回前端。

前端
- 采集项结构化编辑器:名称/路径/标签常驻,多行合并 / JSON / 自定义时间戳 / 命中丢弃 /
  限流折叠在「高级」里;「预览 YAML」弹窗在保存前看到将要下发的配置。
- 本地即时校验 + 旧数据(只有 logPath)自动迁移成 app + system 两个采集项。

=== 三、验证(host-verify-promtail @172.19.16.8)===
- 预览:host=pre-cex-ops-jenkins(无 ${HOSTNAME}),2 job / 3 条系统日志路径 /
  ISO8601 正则 / 多行 + drop + 限流 全部按填写渲染。
- 部署:promtail active、NRestarts=0、-check-syntax 通过,主机侧配置与预览逐行一致。
- Loki:新数据 host 全为 pre-cex-ops-jenkins;job=system 415 条/5min、job=app 40 条/5min。
- 负向 7/7 返回指名到字段的 400:/etc/shadow、/root/.ssh/id_rsa、相对路径、坏正则、
  job 名带空格、保留标签名、空路径。

镜像:lghost/lokistack:latest + v0.1 = sha256:26172f51187d
This commit is contained in:
opsx committed 2026-10-28 18:41:39 +00:00
1 parent 55d6a74eb3
commit c983d0e3c1
18 files changed
+1678 -254

No files matched your search

+159 -29
View File
@@ -5,53 +5,183 @@ import (
"testing"
"gopkg.in/yaml.v3"
"lokistack/models"
)
// 主机模式(PromtailConfig)生成的 YAML 必须能被解析,
// 且系统日志任务不能用 cri 阶段(否则 syslog 格式解析失败丢行)。
func TestHostPromtailConfigValid(t *testing.T) {
cfg := PromtailConfig("http://loki.log.svc:3100", "/var/log/myapp/*.log", "pre", `{"env":"pre"}`)
func renderHost(t *testing.T, o HostPromtailOptions) string {
t.Helper()
cfg, err := PromtailConfigForHost(o)
if err != nil {
t.Fatalf("渲染主机配置失败: %v", err)
}
var out map[string]interface{}
if err := yaml.Unmarshal([]byte(cfg), &out); err != nil {
t.Fatalf("生成的 promtail 配置不是合法 YAML: %v\n%s", err, cfg)
}
if _, ok := out["scrape_configs"]; !ok {
t.Fatal("缺少 scrape_configs")
t.Fatalf("缺少 scrape_configs\n%s", cfg)
}
// 必须包含系统日志任务
if !strings.Contains(cfg, "job_name: system") {
t.Fatal("缺少 system 日志采集任务")
return cfg
}
// 默认(未配置采集项)仍要生成 app + system 两个 job,且系统日志不能套 cri
func TestHostPromtailConfigDefaultJobs(t *testing.T) {
cfg := renderHost(t, HostPromtailOptions{
Endpoint: "http://loki.log.svc:3100",
Cluster: "pre",
Hostname: "pre-cex-ops-jenkins",
LogPath: "/var/log/myapp/*.log",
LabelsJSON: `{"env":"pre"}`,
})
if !strings.Contains(cfg, "job_name: app") || !strings.Contains(cfg, "job_name: system") {
t.Fatalf("默认应有 app + system 两个 job:\n%s", cfg)
}
if !strings.Contains(cfg, "/var/log/{messages,secure,cron,maillog,syslog,dmesg}") {
t.Fatal("system 任务没有采集系统日志路径")
}
// 系统任务段内不能出现 cri 阶段
sysIdx := strings.Index(cfg, "job_name: system")
if sysIdx < 0 {
t.Fatal("找不到 system 任务")
t.Fatal("找不到 system job")
}
if strings.Contains(cfg[sysIdx:], "- cri: {}") {
t.Fatal("system 任务里错误地使用了 cri 阶段")
if strings.Contains(cfg[sysIdx:], "cri: {}") {
t.Fatal("system job 里错误地使用了 cri 阶段")
}
if !strings.Contains(cfg, "env: pre") {
t.Fatal("自定义标签没写进 external_labels")
}
}
// 容器/Cri 路径仍应保留 cri 解析与时间戳提取
func TestHostPromtailCRIPathKeepsCRIStage(t *testing.T) {
cfg := PromtailConfig("http://loki:3100", "/var/log/pods/*/*/*.log", "pre", "")
if !strings.Contains(cfg, "- cri: {}") {
t.Fatal("CRI 路径应保留 cri 阶段")
// Bug3 回归:host 标签必须是真实主机名,不能是 ${HOSTNAME} 字面量
func TestHostPromtailHostnameIsConcrete(t *testing.T) {
cfg := renderHost(t, HostPromtailOptions{
Endpoint: "http://loki:3100",
Hostname: "pre-cex-ops-jenkins",
LogPath: "/data/app/*.log",
})
if strings.Contains(cfg, "${HOSTNAME}") {
t.Fatalf("host 标签仍是 ${HOSTNAME} 字面量(promtail 不做环境变量展开):\n%s", cfg)
}
var out map[string]interface{}
if err := yaml.Unmarshal([]byte(cfg), &out); err != nil {
t.Fatalf("YAML 非法: %v", err)
// 断言"每个 static_config 都带真实主机名"而不是写死数量:
// 默认系统日志有多个文件(messages/secure/cron…),每个文件一条 static_config,
// 数量会随目标机实际存在的日志文件变化。
if n := strings.Count(cfg, "host: pre-cex-ops-jenkins"); n < 2 {
t.Fatalf("app / system 两个 job 都应带真实主机名(实际 %d 处):\n%s", n, cfg)
}
// 即使调用方漏传主机名,也不能退回占位符
cfg2 := renderHost(t, HostPromtailOptions{Endpoint: "http://loki:3100", LogPath: "/data/app/*.log"})
if strings.Contains(cfg2, "${") {
t.Fatalf("空主机名时退化成了占位符:\n%s", cfg2)
}
}
// 普通应用文件不应套 cri,否则非 CRI 行会被丢弃
func TestHostPromtailPlainFileHasNoCRIStage(t *testing.T) {
cfg := PromtailConfig("http://loki:3100", "/data/app/logs/*.log", "pre", "")
if strings.Contains(cfg, "- cri: {}") {
t.Fatal("普通应用日志不应使用 cri 阶段")
// Bug4 回归:syslog 格式决定正则与 timestamp 布局
func TestHostPromtailSyslogFormatSwitch(t *testing.T) {
iso := renderHost(t, HostPromtailOptions{
Endpoint: "http://loki:3100", Hostname: "h1", SyslogFormat: SyslogFormatISO8601,
Jobs: []models.ScrapeJob{{Name: "system", System: true}},
})
if !strings.Contains(iso, `\d{4}-\d{2}-\d{2}T`) {
t.Fatalf("iso8601 模式下应使用 ISO8601 正则:\n%s", iso)
}
if !strings.Contains(iso, "format: RFC3339Nano") {
t.Fatalf("iso8601 模式下 timestamp 应用 RFC3339Nano:\n%s", iso)
}
if strings.Contains(iso, "Jan _2 15:04:05") {
t.Fatalf("iso8601 模式下不该出现传统 syslog 布局:\n%s", iso)
}
classic := renderHost(t, HostPromtailOptions{
Endpoint: "http://loki:3100", Hostname: "h1", SyslogFormat: SyslogFormatClassic,
Jobs: []models.ScrapeJob{{Name: "system", System: true}},
})
if !strings.Contains(classic, "Jan _2 15:04:05") {
t.Fatalf("传统 syslog 模式应使用经典布局:\n%s", classic)
}
if strings.Contains(classic, `\d{4}-\d{2}-\d{2}T`) {
t.Fatalf("传统 syslog 模式不该出现 ISO8601 正则:\n%s", classic)
}
}
// 容器路径仍保留 cri 解析;普通文件不套 cri(否则非 CRI 行会被丢)
func TestHostPromtailCRIStageOnlyForCRIPaths(t *testing.T) {
cri := renderHost(t, HostPromtailOptions{
Endpoint: "http://loki:3100", Hostname: "h1",
Jobs: []models.ScrapeJob{{Name: "pods", Paths: []string{"/var/log/pods/*/*/*.log"}}},
})
if !strings.Contains(cri, "cri: {}") {
t.Fatalf("CRI 路径应保留 cri 阶段:\n%s", cri)
}
plain := renderHost(t, HostPromtailOptions{
Endpoint: "http://loki:3100", Hostname: "h1",
Jobs: []models.ScrapeJob{{Name: "app", Paths: []string{"/data/app/logs/*.log"}}},
})
if strings.Contains(plain, "cri: {}") {
t.Fatalf("普通应用日志不应使用 cri 阶段:\n%s", plain)
}
}
// B 档:多 job / 多路径 / 多行 / 丢弃 / 限流都要真的渲染出来
func TestHostPromtailStructuredJobs(t *testing.T) {
cfg := renderHost(t, HostPromtailOptions{
Endpoint: "http://loki:3100", Hostname: "h1", SyslogFormat: SyslogFormatISO8601,
Jobs: []models.ScrapeJob{
{
Name: "app-api",
Paths: []string{"/data/app/logs/*.log", "/data/app/logs/error/*.log"},
Labels: map[string]string{"app": "cex-api"},
Multiline: &models.JobMultiline{
FirstLine: `^\d{4}-\d{2}-\d{2}`,
MaxLines: 500,
},
JSON: true,
Timestamp: &models.JobTimestamp{Source: "time", Format: "RFC3339Nano"},
DropIfMatch: []string{`"status":200`},
RateLimit: &models.JobRateLimit{Rate: 500, Burst: 900},
},
{Name: "system", System: true},
},
})
for _, want := range []string{
"job_name: app-api",
"/data/app/logs/error/*.log",
"app: cex-api",
"max_lines: 500",
"json: {}",
"drop_counter_reason: drop_if_match",
"rate: 500",
"burst: 900",
"job_name: system",
} {
if !strings.Contains(cfg, want) {
t.Fatalf("配置缺少 %q:\n%s", want, cfg)
}
}
// 两个路径应渲染成两个 static_configs
if strings.Count(cfg, "targets:") != 3 { // app-api 两个路径 + system 默认路径 6 条? 见下
// system 默认路径有 6 条,这里只断言 app-api 的两条
if strings.Count(cfg, "/data/app/logs/") < 2 {
t.Fatalf("多路径未展开成多个 static_configs:\n%s", cfg)
}
}
}
// 校验必须拦死敏感路径和坏正则(这两种都会把主机凭据搬进日志平台 / 让 promtail 起不来)
func TestValidateScrapeJobsRejectsUnsafe(t *testing.T) {
bad := [][]models.ScrapeJob{
{{Name: "x", Paths: []string{"/etc/shadow"}}},
{{Name: "x", Paths: []string{"/root/.ssh/id_rsa"}}},
{{Name: "x", Paths: []string{"relative/path.log"}}},
{{Name: "x", Paths: []string{"/var/log/a.log"}, Multiline: &models.JobMultiline{FirstLine: "(["}}},
{{Name: "x", Paths: []string{"/var/log/a.log"}, DropIfMatch: []string{"(["}}},
{{Name: "x", Paths: []string{"/var/log/a.log"}, Labels: map[string]string{"job": "y"}}},
{{Name: "", Paths: []string{"/var/log/a.log"}}},
{{Name: "x", Paths: nil}},
}
for i, jobs := range bad {
if err := models.ValidateScrapeJobs(jobs); err == nil {
t.Fatalf("第 %d 组非法采集项应被拒绝: %+v", i+1, jobs)
}
}
good := []models.ScrapeJob{{Name: "app", Paths: []string{"/var/log/a.log"}, Labels: map[string]string{"app_name": "x"}}}
if err := models.ValidateScrapeJobs(good); err != nil {
t.Fatalf("合法采集项被误拒: %v", err)
}
}