diff --git a/backend/services/collector_deployer.go b/backend/services/collector_deployer.go index 44b8405..e03669e 100644 --- a/backend/services/collector_deployer.go +++ b/backend/services/collector_deployer.go @@ -200,12 +200,20 @@ func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint strin clusterName = cluster.Name } + manifest := PromtailDaemonsetYAML(ns, endpoint, clusterName, d.collector.Labels, + d.collector.TargetNamespaces, d.collector.AppLabelSource, d.collector.AppInclude) + + // 关键顺序:先本地校验清单,再删旧资源。 + // 否则模板有误时会出现「旧 DaemonSet 已删、新的建不出来」→ 日志采集直接中断。 + if err := validateManifest(manifest); err != nil { + d.log("error", fmt.Sprintf("生成的 Promtail 清单无效,已中止且未改动现有资源: %v", err), "") + return fmt.Errorf("Promtail 清单校验失败(现有采集器未受影响): %w", err) + } + // 删除历史残留并通过 Kubernetes API 提交资源。 for _, kind := range []string{"daemonset", "configmap", "serviceaccount"} { _ = deleteK8sResource(ctx, cluster, kind, ns, "promtail") } - manifest := PromtailDaemonsetYAML(ns, endpoint, clusterName, d.collector.Labels, - d.collector.TargetNamespaces, d.collector.AppLabelSource, d.collector.AppInclude) if err := d.applyYAML(ctx, nil, manifest, "promtail"); err != nil { return err } @@ -245,7 +253,13 @@ func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error { if ns == "" { ns = "log" } - for _, item := range [][2]string{{"daemonset", "promtail"}, {"configmap", "promtail"}, {"serviceaccount", "promtail"}, {"clusterrole", "opsxloki-promtail"}, {"clusterrolebinding", "opsxloki-promtail"}} { + // clusterrole / clusterrolebinding 用 loki-promtail; + // 旧版本曾用 opsxloki-promtail,这里一并清理以免升级后留下孤儿资源。 + for _, item := range [][2]string{ + {"daemonset", "promtail"}, {"configmap", "promtail"}, {"serviceaccount", "promtail"}, + {"clusterrole", "loki-promtail"}, {"clusterrolebinding", "loki-promtail"}, + {"clusterrole", "opsxloki-promtail"}, {"clusterrolebinding", "opsxloki-promtail"}, + } { if err := deleteK8sResource(ctx, cluster, item[0], ns, item[1]); err != nil { return err } diff --git a/backend/services/collector_templates.go b/backend/services/collector_templates.go index e28d5b7..e2a53c8 100644 --- a/backend/services/collector_templates.go +++ b/backend/services/collector_templates.go @@ -287,7 +287,7 @@ metadata: apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: opsxloki-promtail + name: loki-promtail rules: - apiGroups: [""] resources: ["pods", "nodes"] @@ -296,11 +296,11 @@ rules: apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: opsxloki-promtail + name: loki-promtail roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: opsxloki-promtail + name: loki-promtail subjects: - kind: ServiceAccount name: promtail @@ -482,7 +482,7 @@ spec: - name: containers hostPath: path: /var/lib/docker/containers -`, ns, ns, ns, lokiURL, extLabels, nsFilter, appSourceLine, promtailImage, appIncludeFilter) +`, ns, ns, ns, lokiURL, extLabels, nsFilter, appSourceLine, appIncludeFilter, ns, promtailImage) } // FluentbitConfig Sidecar 模式 Fluent Bit 配置(采集 logPath,推送至 lokiHost:lokiPort) diff --git a/backend/services/k8s_resources.go b/backend/services/k8s_resources.go index 59d5b7f..84b4ba6 100644 --- a/backend/services/k8s_resources.go +++ b/backend/services/k8s_resources.go @@ -39,6 +39,39 @@ func ensureNamespace(ctx context.Context, cluster *models.Cluster, namespace str return err } +// validateManifest 只做本地 YAML 解析校验,不访问集群。 +// +// 用途:采集器 install 流程是「先删旧资源再 apply」,一旦模板生成有误 +// (例如 fmt.Sprintf 占位符错位产生 %!s(MISSING)),就会出现 +// 「旧的已删、新的没建成」→ 日志采集中断。所以在删之前先跑一遍解析。 +func validateManifest(manifest string) error { + decoder := yamlutil.NewYAMLOrJSONDecoder(strings.NewReader(manifest), 4096) + count := 0 + for { + var raw map[string]interface{} + if err := decoder.Decode(&raw); err == io.EOF { + break + } else if err != nil { + return fmt.Errorf("解析 Kubernetes YAML: %w", err) + } + if len(raw) == 0 { + continue + } + obj := &unstructured.Unstructured{Object: raw} + if obj.GetKind() == "" || obj.GetAPIVersion() == "" { + return fmt.Errorf("清单缺少 apiVersion/kind") + } + if obj.GetName() == "" { + return fmt.Errorf("Kubernetes %s 缺少 metadata.name", obj.GetKind()) + } + count++ + } + if count == 0 { + return fmt.Errorf("清单为空") + } + return nil +} + // applyManifest 使用 API Server 的 server-side apply,保留已有 YAML 模板与字段所有权。 func applyManifest(ctx context.Context, cluster *models.Cluster, manifest string) error { cfg, err := clusterRESTConfig(cluster, 30*time.Second) diff --git a/backend/services/promtail_manifest_test.go b/backend/services/promtail_manifest_test.go new file mode 100644 index 0000000..17ee3f2 --- /dev/null +++ b/backend/services/promtail_manifest_test.go @@ -0,0 +1,66 @@ +package services + +import ( + "bytes" + "io" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/util/yaml" +) + +// 复现平台 install 时的 YAML 解析,定位非法 token +func TestPromtailManifestParses(t *testing.T) { + cases := []struct { + name string + labels string + ns []string + src string + inc []string + }{ + {"空labels+空ns+空来源", "", nil, "", nil}, + {"正常labels", `{"cluster":"pre-k8s"}`, []string{"astralx", "log"}, "k8s.kuboard.cn/name", []string{"cex-spot-api"}}, + {"空labels但带ns", "", []string{"astralx"}, "controller_name", []string{"a", "b"}}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out := PromtailDaemonsetYAML("log", "http://loki.log.svc.cluster.local:3100", "pre-k8s", c.labels, c.ns, c.src, c.inc) + + // 1) 扫控制字符 + for i, line := range strings.Split(out, "\n") { + if strings.ContainsRune(line, '\t') { + t.Errorf("line %d contains TAB: %q", i+1, line) + } + } + + // 2) 用平台同一套 decoder 解析 + dec := yaml.NewYAMLOrJSONDecoder(bytes.NewReader([]byte(out)), 4096) + n := 0 + for { + var raw map[string]interface{} + if err := dec.Decode(&raw); err == io.EOF { + break + } else if err != nil { + lines := strings.Split(out, "\n") + // 打印报错行附近 + var from, to int + if len(lines) > 30 { + from, to = 1, 30 + } else { + from, to = 1, len(lines) + } + for i := from - 1; i < to && i < len(lines); i++ { + t.Logf("%3d| %q", i+1, lines[i]) + } + t.Fatalf("YAML decode failed: %v", err) + } + n++ + } + t.Logf("解析出 %d 个对象", n) + if n < 4 { + t.Errorf("期望 >=4 个对象(SA/ClusterRole/CRB/ConfigMap/DaemonSet),实际 %d", n) + } + }) + } +} diff --git a/frontend/src/pages/logs/components/QuickQueryTemplates.tsx b/frontend/src/pages/logs/components/QuickQueryTemplates.tsx index 4c4c499..4fe9dff 100644 --- a/frontend/src/pages/logs/components/QuickQueryTemplates.tsx +++ b/frontend/src/pages/logs/components/QuickQueryTemplates.tsx @@ -6,6 +6,9 @@ import { applyLabelFilter, extractSelector } from '../query-utils' type Template = 'app' | 'error' | 'warn' | 'pod' | 'ingress' +/** Ingress 访问日志所在命名空间候选(按顺序取第一个实际存在的) */ +const INGRESS_NAMESPACE_CANDIDATES = ['ingress-nginx', 'ingress'] + function selectorOf(labels: Record) { const entries = Object.entries(labels).filter(([, value]) => value.trim()) if (entries.length === 0) return '' @@ -117,15 +120,26 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }: } function generate(template: Template) { + // Ingress 访问日志与当前选中的业务命名空间无关: + // ingress controller 的 Pod 只在它自己的命名空间(ingress-nginx / ingress)里, + // 所以这里固定用该命名空间,并且不叠加 app / pod 过滤(那些属于业务服务)。 + // 原实现 `namespace.trim() || 'ingress-nginx'` 会导致选了 monitor 就变成 + // {namespace="monitor"} |= "HTTP/",一条都查不到。 + if (template === 'ingress') { + const ingressNs = + INGRESS_NAMESPACE_CANDIDATES.find((n) => namespaces.includes(n)) ?? + INGRESS_NAMESPACE_CANDIDATES[0] + onQueryChange(`{namespace="${ingressNs}"} |= "HTTP/"`) + return + } + let next = extractSelector(query) || '{}' - if (template === 'ingress') next = applyLabelFilter(next, 'namespace', namespace.trim() || 'ingress-nginx') - else if (namespace.trim()) next = applyLabelFilter(next, 'namespace', namespace.trim()) + if (namespace.trim()) next = applyLabelFilter(next, 'namespace', namespace.trim()) if (app.trim()) next = applyLabelFilter(next, appLabel, app.trim()) if (pod.trim()) next = applyLabelFilter(next, 'pod', pod.trim()) if (next === '{}') return if (template === 'error') next += ' |= "error"' if (template === 'warn') next += ' |= "warn"' - if (template === 'ingress') next += ' |= "HTTP/"' onQueryChange(next) }