From b6e5238358c715b930ce66663dbf18e4acf04a95 Mon Sep 17 00:00:00 2001 From: phil Date: Wed, 12 Aug 2026 23:04:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(rules):=20Prometheus=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=20=EB=A3=B0=20=ED=8C=8C=EC=9D=BC=20=EB=A6=AC=EB=8D=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go 표준 라이브러리에 YAML 파서가 없고 이 제품은 외부 의존성을 두지 않으므로 직접 읽는다. 다만 일반 YAML 파서는 만들지 않는다 — 스키마가 고정돼 있으므로 그것만 아는 리더가 훨씬 작고, 지원하지 않는 문법을 조용히 삼킬 위험도 없다. 앵커·멀티라인 블록은 룰 파일에 쓰이지 않으므로 만나면 에러를 낸다. - 파일 하나라도 잘못되면 전체가 실패한다. 절반만 로드되면 어떤 알림이 살아 있는지 아무도 모른다. - record 룰은 이름을 밝히며 거절한다(알림 룰만 받는다). - 주석은 따옴표 **밖**의 # 부터 지운다 — 안까지 지우면 summary: "번호 #42" 가 잘린다. - 값의 콜론은 그대로 둔다. PromQL 식에는 콜론이 흔하다. - 로드 순서를 파일시스템에 맡기지 않고 이름순으로 고정한다. --- internal/rules/load.go | 291 ++++++++++++++++++++++++++++++++++++ internal/rules/load_test.go | 179 ++++++++++++++++++++++ 2 files changed, 470 insertions(+) create mode 100644 internal/rules/load.go create mode 100644 internal/rules/load_test.go diff --git a/internal/rules/load.go b/internal/rules/load.go new file mode 100644 index 0000000..0e7d97e --- /dev/null +++ b/internal/rules/load.go @@ -0,0 +1,291 @@ +package rules + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// 룰 파일은 Prometheus 형식(YAML)이다. Go 표준 라이브러리에 YAML 파서가 없고 +// 이 제품은 외부 의존성을 두지 않으므로 직접 읽는다. +// +// 일반 YAML 파서를 만들지는 않는다 — 스키마가 고정돼 있으므로 그것만 아는 +// 리더가 훨씬 작고, 지원하지 않는 문법을 조용히 삼킬 위험도 없다. 앵커· +// 멀티라인 블록·중첩 시퀀스는 룰 파일에 쓰이지 않으므로 지원하지 않고, +// 만나면 에러를 낸다. + +// Group 은 같은 주기로 평가되는 룰 묶음이다. +type Group struct { + Name string + Interval time.Duration + Rules []*Rule +} + +// LoadDir 은 디렉터리의 *.yaml / *.yml 을 모두 읽어 그룹 목록을 만든다. +// 파일 하나라도 잘못되면 전체가 실패한다 — 절반만 로드되면 어떤 알림이 살아 +// 있는지 아무도 모른다. +func LoadDir(dir string) ([]*Group, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("rules: 룰 디렉터리 %s: %w", dir, err) + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(e.Name())) + if ext == ".yaml" || ext == ".yml" { + names = append(names, e.Name()) + } + } + sort.Strings(names) // 로드 순서를 파일시스템에 맡기지 않는다 + + var out []*Group + for _, n := range names { + body, err := os.ReadFile(filepath.Join(dir, n)) + if err != nil { + return nil, fmt.Errorf("rules: %s: %w", n, err) + } + gs, err := parseRuleFile(string(body)) + if err != nil { + return nil, fmt.Errorf("rules: %s: %w", n, err) + } + out = append(out, gs...) + } + return out, nil +} + +// rawRule 은 파싱 중간 형태다. 식 파싱은 모든 필드를 모은 뒤에 한다 — for 나 +// labels 가 뒤에 와도 되게. +type rawRule struct { + alert string + record string + expr string + forStr string + labels map[string]string + anns map[string]string + line int +} + +func parseRuleFile(src string) ([]*Group, error) { + var groups []*Group + var cur *Group + var rr *rawRule + // mapTarget 은 지금 labels/annotations 블록 안인지, 그 블록의 들여쓰기가 + // 얼마인지를 기억한다. + var mapTarget map[string]string + mapIndent := -1 + + flush := func() error { + if rr == nil { + return nil + } + r, err := buildRule(rr) + if err != nil { + return err + } + cur.Rules = append(cur.Rules, r) + rr = nil + return nil + } + + for i, raw := range strings.Split(src, "\n") { + lineNo := i + 1 + line := stripComment(raw) + if strings.TrimSpace(line) == "" { + continue + } + indent := len(line) - len(strings.TrimLeft(line, " ")) + body := strings.TrimSpace(line) + + // labels/annotations 블록 안이면, 더 깊은 들여쓰기는 그 맵의 항목이다. + if mapTarget != nil { + if indent > mapIndent { + k, v, ok := splitKV(body) + if !ok { + return nil, fmt.Errorf("%d 행: 맵 항목이 아니다: %q", lineNo, body) + } + mapTarget[k] = v + continue + } + mapTarget, mapIndent = nil, -1 + } + + switch { + case body == "groups:": + continue + + case strings.HasPrefix(body, "- "): + item := strings.TrimSpace(body[2:]) + k, v, ok := splitKV(item) + if !ok { + return nil, fmt.Errorf("%d 행: 목록 항목에 키가 없다: %q", lineNo, item) + } + switch k { + case "name": // 새 그룹 + if err := flush(); err != nil { + return nil, err + } + cur = &Group{Name: v} + groups = append(groups, cur) + case "alert", "record": // 새 룰 + if cur == nil { + return nil, fmt.Errorf("%d 행: 그룹 밖에 룰이 있다", lineNo) + } + if err := flush(); err != nil { + return nil, err + } + rr = &rawRule{labels: map[string]string{}, anns: map[string]string{}, line: lineNo} + if k == "alert" { + rr.alert = v + } else { + rr.record = v + } + default: + return nil, fmt.Errorf("%d 행: 목록 항목이 name/alert/record 로 시작하지 않는다: %q", lineNo, k) + } + continue + } + + k, v, ok := splitKV(body) + if !ok { + return nil, fmt.Errorf("%d 행: 해석할 수 없다: %q", lineNo, body) + } + + if rr != nil { + switch k { + case "expr": + rr.expr = v + case "for": + rr.forStr = v + case "labels": + mapTarget, mapIndent = rr.labels, indent + case "annotations": + mapTarget, mapIndent = rr.anns, indent + case "alert": + rr.alert = v + case "record": + rr.record = v + default: + return nil, fmt.Errorf("%d 행: 룰에 없는 필드 %q", lineNo, k) + } + continue + } + + if cur != nil { + switch k { + case "interval": + d, err := parseDuration(v) + if err != nil { + return nil, fmt.Errorf("%d 행: interval: %w", lineNo, err) + } + cur.Interval = d + case "rules": + // 다음 목록 항목이 룰이다. + case "name": + cur.Name = v + default: + return nil, fmt.Errorf("%d 행: 그룹에 없는 필드 %q", lineNo, k) + } + continue + } + return nil, fmt.Errorf("%d 행: groups 밖의 내용: %q", lineNo, body) + } + if err := flush(); err != nil { + return nil, err + } + return groups, nil +} + +func buildRule(rr *rawRule) (*Rule, error) { + if rr.record != "" { + return nil, fmt.Errorf("%d 행: record 룰은 지원하지 않는다(알림 룰만 받는다)", rr.line) + } + if rr.alert == "" { + return nil, fmt.Errorf("%d 행: alert 이름이 없다", rr.line) + } + if rr.expr == "" { + return nil, fmt.Errorf("%d 행: 룰 %q 에 expr 이 없다", rr.line, rr.alert) + } + var forDur time.Duration + if rr.forStr != "" { + d, err := parseDuration(rr.forStr) + if err != nil { + return nil, fmt.Errorf("%d 행: 룰 %q 의 for: %w", rr.line, rr.alert, err) + } + forDur = d + } + return NewRule(rr.alert, rr.expr, forDur, rr.labels, rr.anns) +} + +// splitKV 는 첫 콜론으로 키와 값을 나눈다. 값에 콜론이 더 있어도 그대로 둔다 +// — PromQL 식에는 콜론이 흔하다. +func splitKV(s string) (string, string, bool) { + i := strings.Index(s, ":") + if i < 0 { + return "", "", false + } + k := strings.TrimSpace(s[:i]) + v := strings.TrimSpace(s[i+1:]) + if k == "" { + return "", "", false + } + return k, unquote(v), true +} + +func unquote(s string) string { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} + +// stripComment 는 따옴표 **밖**의 # 부터를 지운다. 안쪽까지 지우면 +// summary: "번호 #42" 가 잘린다. +func stripComment(line string) string { + var quote byte + for i := 0; i < len(line); i++ { + c := line[i] + switch { + case quote != 0: + if c == quote { + quote = 0 + } + case c == '"' || c == '\'': + quote = c + case c == '#': + return strings.TrimRight(line[:i], " ") + } + } + return strings.TrimRight(line, " ") +} + +// parseDuration 은 30s·5m·2h 같은 Prometheus duration 을 읽는다. Go 의 +// time.ParseDuration 은 d·w·y 를 모른다. +func parseDuration(s string) (time.Duration, error) { + units := []struct { + suffix string + d time.Duration + }{ + {"ms", time.Millisecond}, {"s", time.Second}, {"m", time.Minute}, + {"h", time.Hour}, {"d", 24 * time.Hour}, {"w", 7 * 24 * time.Hour}, + {"y", 365 * 24 * time.Hour}, + } + for _, u := range units { + if len(s) > len(u.suffix) && strings.HasSuffix(s, u.suffix) { + n, err := strconv.ParseFloat(strings.TrimSuffix(s, u.suffix), 64) + if err != nil { + continue + } + return time.Duration(n * float64(u.d)), nil + } + } + return 0, fmt.Errorf("%q 는 duration 형식이 아니다(예: 30s, 5m)", s) +} diff --git a/internal/rules/load_test.go b/internal/rules/load_test.go new file mode 100644 index 0000000..6e99db2 --- /dev/null +++ b/internal/rules/load_test.go @@ -0,0 +1,179 @@ +package rules + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func write(t *testing.T, dir, name, body string) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + return p +} + +const sampleRules = `# 하드웨어 알림 +groups: + - name: hardware + interval: 30s + rules: + - alert: GpuXidCritical + expr: increase(nodevitals_events_total{condition="gpu_xid_error"}[5m]) > 0 + for: 0s + labels: + severity: critical + team: platform + annotations: + summary: "XID 오류" + - alert: DiskWearout + expr: nvme_percentage_used > 90 + for: 10m + labels: + severity: warning +` + +func TestLoadParsesGroupsAndRules(t *testing.T) { + dir := t.TempDir() + write(t, dir, "hw.yaml", sampleRules) + + groups, err := LoadDir(dir) + if err != nil { + t.Fatalf("LoadDir: %v", err) + } + if len(groups) != 1 { + t.Fatalf("그룹 %d 개, want 1", len(groups)) + } + g := groups[0] + if g.Name != "hardware" { + t.Errorf("name=%q", g.Name) + } + if g.Interval != 30*time.Second { + t.Errorf("interval=%v, want 30s", g.Interval) + } + if len(g.Rules) != 2 { + t.Fatalf("룰 %d 개, want 2", len(g.Rules)) + } + + r := g.Rules[0] + if r.Name != "GpuXidCritical" { + t.Errorf("alert=%q", r.Name) + } + if r.For != 0 { + t.Errorf("for=%v, want 0", r.For) + } + if r.Labels["severity"] != "critical" || r.Labels["team"] != "platform" { + t.Errorf("labels=%+v", r.Labels) + } + if r.Annotations["summary"] != "XID 오류" { + t.Errorf("annotations=%+v", r.Annotations) + } + // 식에 중괄호·대괄호·따옴표가 섞여 있어도 온전해야 한다. + if !strings.Contains(r.Expression(), `condition="gpu_xid_error"`) { + t.Errorf("expr 이 잘렸다: %q", r.Expression()) + } + + if g.Rules[1].For != 10*time.Minute { + t.Errorf("두 번째 룰 for=%v, want 10m", g.Rules[1].For) + } +} + +// 식이 틀린 룰이 하나 있으면 그 파일 전체를 거절한다 — 절반만 로드되면 +// 어떤 알림이 살아 있는지 아무도 모른다. +func TestLoadRejectsFileWithBadExpression(t *testing.T) { + dir := t.TempDir() + write(t, dir, "bad.yaml", `groups: + - name: g + rules: + - alert: A + expr: topk(3, x) +`) + if _, err := LoadDir(dir); err == nil { + t.Error("미지원 문법이 든 파일이 통과했다") + } +} + +func TestLoadRejectsRecordingRules(t *testing.T) { + dir := t.TempDir() + write(t, dir, "rec.yaml", `groups: + - name: g + rules: + - record: job:x:sum + expr: sum(x) +`) + _, err := LoadDir(dir) + if err == nil { + t.Fatal("recording rule 이 통과했다") + } + if !strings.Contains(err.Error(), "record") { + t.Errorf("에러가 record 를 언급하지 않는다: %v", err) + } +} + +// 주석과 빈 줄이 섞여도 읽혀야 한다. +func TestLoadHandlesCommentsAndBlankLines(t *testing.T) { + dir := t.TempDir() + write(t, dir, "c.yaml", ` +# 맨 위 주석 + +groups: + + # 그룹 주석 + - name: g + rules: + - alert: A + expr: x > 1 # 뒤 주석 +`) + groups, err := LoadDir(dir) + if err != nil { + t.Fatalf("LoadDir: %v", err) + } + if len(groups) != 1 || len(groups[0].Rules) != 1 { + t.Fatalf("결과 %+v", groups) + } + // 값 뒤 주석은 떨어져야 하지만, 따옴표 안의 # 는 남아야 한다. + if got := groups[0].Rules[0].Expression(); got != "x > 1" { + t.Errorf("expr=%q, want %q", got, "x > 1") + } +} + +func TestLoadKeepsHashInsideQuotes(t *testing.T) { + dir := t.TempDir() + write(t, dir, "q.yaml", `groups: + - name: g + rules: + - alert: A + expr: x > 1 + annotations: + summary: "번호 #42" +`) + groups, err := LoadDir(dir) + if err != nil { + t.Fatalf("LoadDir: %v", err) + } + if got := groups[0].Rules[0].Annotations["summary"]; got != "번호 #42" { + t.Errorf("summary=%q — 따옴표 안의 # 가 주석으로 잘렸다", got) + } +} + +func TestLoadDirIgnoresNonYAML(t *testing.T) { + dir := t.TempDir() + write(t, dir, "notes.txt", "이건 룰이 아니다") + write(t, dir, "ok.yaml", `groups: + - name: g + rules: + - alert: A + expr: x > 1 +`) + groups, err := LoadDir(dir) + if err != nil { + t.Fatalf("LoadDir: %v", err) + } + if len(groups) != 1 { + t.Errorf("그룹 %d 개, want 1 (.txt 는 무시)", len(groups)) + } +}