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) } }