Files
opsx c983d0e3c1 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
2026-10-28 18:41:39 +00:00

188 lines
6.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"strings"
"testing"
"gopkg.in/yaml.v3"
"lokistack/models"
)
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.Fatalf("缺少 scrape_configs\n%s", cfg)
}
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)
}
sysIdx := strings.Index(cfg, "job_name: system")
if sysIdx < 0 {
t.Fatal("找不到 system job")
}
if strings.Contains(cfg[sysIdx:], "cri: {}") {
t.Fatal("system job 里错误地使用了 cri 阶段")
}
if !strings.Contains(cfg, "env: pre") {
t.Fatal("自定义标签没写进 external_labels")
}
}
// 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)
}
// 断言"每个 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)
}
}
// 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)
}
}