diff --git a/backend/controllers/storage.go b/backend/controllers/storage.go index ed9e867..4e81213 100644 --- a/backend/controllers/storage.go +++ b/backend/controllers/storage.go @@ -3,6 +3,7 @@ package controllers import ( "opsxloki/dto" "opsxloki/models" + "opsxloki/pkg/crypto" "opsxloki/services" "opsxloki/utils" @@ -103,13 +104,31 @@ func (ctrl *StorageController) TestConnection(c *gin.Context) { if !utils.BindAndValidate(c, &req) { return } + accessKey, secretKey := req.AccessKey, req.SecretKey + // SecretKey 出于安全不返回给前端;编辑态测试时前端留空, + // 这里用库里保存的凭据补齐(AccessKey 同理,前端通常会带上,但不强制)。 + if (accessKey == "" || secretKey == "") && req.ID > 0 { + if cfg, err := ctrl.service.Get(req.ID); err == nil { + if accessKey == "" { + accessKey = crypto.DecryptField(cfg.AccessKey) + } + if secretKey == "" { + secretKey = crypto.DecryptField(cfg.SecretKey) + } + } + } + if accessKey == "" || secretKey == "" { + utils.BadRequest(c, "缺少 Access Key / Secret Key:新建存储时必须填写") + return + } + result := services.TestStorageConnection(services.StorageTestParams{ StorageType: req.StorageType, Endpoint: req.Endpoint, Bucket: req.Bucket, Region: req.Region, - AccessKey: req.AccessKey, - SecretKey: req.SecretKey, + AccessKey: accessKey, + SecretKey: secretKey, Insecure: req.Insecure, CreateIfMissing: req.CreateIfMissing, }) diff --git a/backend/dto/storage.go b/backend/dto/storage.go index 9e629ed..bef3265 100644 --- a/backend/dto/storage.go +++ b/backend/dto/storage.go @@ -16,12 +16,15 @@ type CreateStorageRequest struct { // StorageTestRequest 测试连接(不落库;createIfMissing 时桶不存在则自动创建) type StorageTestRequest struct { + // ID: 编辑已有存储时传入,SecretKey/AccessKey 留空则用库里已保存的凭据 + // (SecretKey 出于安全不再下发给前端,测试时必须走这条兜底路径) + ID uint `json:"id"` StorageType string `json:"storageType" binding:"required,oneof=s3 oss minio rustfs cos obs"` Endpoint string `json:"endpoint" binding:"required"` Bucket string `json:"bucket" binding:"required"` Region string `json:"region"` - AccessKey string `json:"accessKey" binding:"required"` - SecretKey string `json:"secretKey" binding:"required"` + AccessKey string `json:"accessKey"` + SecretKey string `json:"secretKey"` Insecure bool `json:"insecure"` CreateIfMissing bool `json:"createIfMissing"` } diff --git a/backend/models/collector.go b/backend/models/collector.go index a183157..a9857a0 100644 --- a/backend/models/collector.go +++ b/backend/models/collector.go @@ -15,7 +15,10 @@ type Collector struct { ClusterID *uint `json:"clusterID,omitempty"` Cluster *Cluster `json:"cluster,omitempty"` TargetDeployment string `json:"targetDeployment"` // sidecar 专用:目标 deployment 名 - Namespace string `json:"namespace"` // k8s 命名空间(默认 logging) + Namespace string `json:"namespace"` // 采集器自身部署所在的 k8s 命名空间 + // TargetNamespaces 只采集这些命名空间的 Pod 日志;为空表示采集全集群。 + // 对应 promtail 里 kubernetes_sd_configs.namespaces.names + TargetNamespaces []string `json:"targetNamespaces"` LogPath string `json:"logPath"` // 采集日志路径 Labels string `json:"labels"` // 额外标签(JSON) Status string `json:"status"` // pending | active | error diff --git a/backend/models/storage.go b/backend/models/storage.go index e64d740..0372063 100644 --- a/backend/models/storage.go +++ b/backend/models/storage.go @@ -23,10 +23,13 @@ type StorageConfig struct { } func (s *StorageConfig) Sanitized() map[string]interface{} { - // AccessKey / SecretKey 在库里是 ENC: 加密存储, - // 直接返回会让前端编辑框显示 ENC:xxx 密文。这里统一解密后返回明文, - // 前端 Secret Key 仍用密码框(默认掩码,点「显示」才展开), - // 便于管理员核对凭据并「测试连接」。 + // 凭据在库里是 ENC: 加密存储。 + // + // 安全约定: + // - AccessKey 解密后明文返回(管理员需要核对自己用的是哪个 AK) + // - SecretKey 一律不返回明文,只给 hasSecretKey 标记 + // —— 避免任何登录用户点一下「显示」按钮就能拿到 SK。 + // 编辑时前端留空表示「不修改」,测试连接由后端用库里保存的 SK 兜底。 plainAK := crypto.DecryptField(s.AccessKey) plainSK := crypto.DecryptField(s.SecretKey) @@ -37,13 +40,13 @@ func (s *StorageConfig) Sanitized() map[string]interface{} { "endpoint": s.Endpoint, "bucket": s.Bucket, "region": s.Region, - "accessKey": plainAK, - "secretKey": plainSK, - "pathPrefix": s.PathPrefix, - "insecure": s.Insecure, - "isDefault": s.IsDefault, + "accessKey": plainAK, + "secretKey": "", + "pathPrefix": s.PathPrefix, + "insecure": s.Insecure, + "isDefault": s.IsDefault, "hasSecretKey": plainSK != "", - "createdAt": s.CreatedAt, - "updatedAt": s.UpdatedAt, + "createdAt": s.CreatedAt, + "updatedAt": s.UpdatedAt, } } diff --git a/backend/services/collector.go b/backend/services/collector.go index 90e78a4..7aec52c 100644 --- a/backend/services/collector.go +++ b/backend/services/collector.go @@ -3,6 +3,7 @@ package services import ( "context" "fmt" + "strings" "time" "opsxloki/models" @@ -209,6 +210,44 @@ func (s *CollectorService) Update(id uint, updates map[string]interface{}) error if v, ok := updates["labels"].(string); ok { db.Collectors[i].Labels = v } + // targetNamespaces: 采集哪些命名空间([]string;空=全集群) + if v, ok := updates["targetNamespaces"]; ok { + var nss []string + switch t := v.(type) { + case []interface{}: + for _, item := range t { + if s, ok := item.(string); ok { + if s = strings.TrimSpace(s); s != "" { + nss = append(nss, s) + } + } + } + case []string: + for _, s := range t { + if s = strings.TrimSpace(s); s != "" { + nss = append(nss, s) + } + } + case string: + // 兼容 "astralx,log" 这种逗号分隔写法 + for _, s := range strings.Split(t, ",") { + if s = strings.TrimSpace(s); s != "" { + nss = append(nss, s) + } + } + } + if len(nss) != len(db.Collectors[i].TargetNamespaces) { + db.Collectors[i].StatusMessage = "采集命名空间已保存,点击部署以应用新配置" + } else { + for j := range nss { + if nss[j] != db.Collectors[i].TargetNamespaces[j] { + db.Collectors[i].StatusMessage = "采集命名空间已保存,点击部署以应用新配置" + break + } + } + } + db.Collectors[i].TargetNamespaces = nss + } db.Collectors[i].UpdatedAt = time.Now() db.RequestSave() return nil diff --git a/backend/services/collector_deployer.go b/backend/services/collector_deployer.go index 3d191a9..bbaf1eb 100644 --- a/backend/services/collector_deployer.go +++ b/backend/services/collector_deployer.go @@ -204,7 +204,7 @@ func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint strin for _, kind := range []string{"daemonset", "configmap", "serviceaccount"} { _ = deleteK8sResource(ctx, cluster, kind, ns, "promtail") } - manifest := PromtailDaemonsetYAML(ns, endpoint, clusterName, d.collector.Labels) + manifest := PromtailDaemonsetYAML(ns, endpoint, clusterName, d.collector.Labels, d.collector.TargetNamespaces) if err := d.applyYAML(ctx, nil, manifest, "promtail"); err != nil { return err } diff --git a/backend/services/collector_templates.go b/backend/services/collector_templates.go index d2e732c..cb6e740 100644 --- a/backend/services/collector_templates.go +++ b/backend/services/collector_templates.go @@ -122,25 +122,28 @@ __EXT_LABELS__ burst: 10000 drop: true relabel_configs: - - source_labels: [__meta_kubernetes_pod_controller_name] + - source_labels: [__meta_kubernetes_pod_phase] + regex: (Succeeded|Failed) + action: drop + - source_labels: [__meta_kubernetes_pod_controller_name] regex: ([0-9a-z-.]+?)(-[0-9a-f]{8,10})? action: replace target_label: __tmp_controller_name - - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name] - regex: .+ - action: replace - target_label: app - - source_labels: [__meta_kubernetes_pod_label_app] - regex: .+ - action: replace - target_label: app + - source_labels: + - __meta_kubernetes_pod_label_app_kubernetes_io_name + - __meta_kubernetes_pod_label_app + - __tmp_controller_name + - __meta_kubernetes_pod_name + regex: ^;*([^;]+)(;.*)?$ + action: replace + target_label: app - source_labels: [__meta_kubernetes_pod_annotation_kubernetes_io_config_hash] - regex: .+ - action: replace - target_label: config_hash + regex: .+ + action: replace + target_label: config_hash - action: replace - source_labels: ['__meta_kubernetes__pod_name'] - target_label: 'pod' + source_labels: ['__meta_kubernetes_pod_name'] + target_label: 'pod' - action: replace source_labels: ['__meta_kubernetes_namespace'] target_label: 'namespace' @@ -193,9 +196,32 @@ extraVolumeMounts: // PromtailDaemonsetYAML DaemonSet 模式纯 YAML 安装(SA/RBAC/ConfigMap/DaemonSet), // 取代 helm 安装 —— 后端无需安装 helm 二进制。 // relabel 体系与主流 helm chart 对齐:namespace/pod/container/node_name + __path__ 按 pod uid 精确匹配。 -func PromtailDaemonsetYAML(ns, lokiURL, clusterName, labelsJSON string) string { +// promtailNamespaceFilter 生成 kubernetes_sd_configs 的命名空间白名单。 +// namespaces 为空 → 返回空串(采集全集群所有命名空间)。 +func promtailNamespaceFilter(namespaces []string) string { + clean := make([]string, 0, len(namespaces)) + for _, n := range namespaces { + if n = strings.TrimSpace(n); n != "" { + clean = append(clean, n) + } + } + if len(clean) == 0 { + return "" + } + var b strings.Builder + b.WriteString(" namespaces:\n names:\n") + for _, n := range clean { + b.WriteString(" - ") + b.WriteString(n) + b.WriteString("\n") + } + return b.String() +} + +func PromtailDaemonsetYAML(ns, lokiURL, clusterName, labelsJSON string, targetNamespaces []string) string { promtailImage := ResolveImageFor("promtail", "grafana/promtail", "3.4.0") extLabels := promtailExternalLabels(clusterName, labelsJSON) + nsFilter := promtailNamespaceFilter(targetNamespaces) return fmt.Sprintf(`apiVersion: v1 kind: ServiceAccount metadata: @@ -245,31 +271,86 @@ data: max_period: 5m max_retries: 10 external_labels: - job: promtail %s scrape_configs: - job_name: kubernetes-pods kubernetes_sd_configs: - role: pod +%s + pipeline_stages: + - cri: {} + - multiline: + firstline: '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}[+\\-]\\d{2}:\\d{2}' + max_wait_time: 3s + max_lines: 500 + - timestamp: + source: time + format: RFC3339Nano relabel_configs: - - source_labels: [__meta_kubernetes_namespace] - target_label: namespace - - source_labels: [__meta_kubernetes_pod_name] - target_label: pod - - source_labels: [__meta_kubernetes_pod_container_name] - target_label: container - - source_labels: [__meta_kubernetes_pod_node_name] - target_label: node_name - - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name, __meta_kubernetes_pod_label_app] + # 跳过已终止的 Pod(Evicted / Failed / Succeeded)。 + # 实证:某服务残留 387 个 Evicted Pod,日志文件还在节点上, + # Promtail 会把它们的历史异常堆栈全量采回来,导致 15 分钟 27 万条的假象, + # 也会污染 Loki 存储和查询结果。 + - source_labels: [__meta_kubernetes_pod_phase] + regex: (Succeeded|Failed) + action: drop + # 从 controller name 提取 deployment 名(去掉 replicaset 哈希) + - source_labels: [__meta_kubernetes_pod_controller_name] + regex: ([0-9a-z-.]+?)(-[0-9a-f]{8,10})? + action: replace + target_label: __tmp_controller_name + # app 四级回退 —— 全部基于 Kubernetes 原生元数据,跨集群通用: + # 1) app.kubernetes.io/name ← K8s 标准标签约定 + # 2) app ← 最常见的简写 + # 3) controller name 去 hash ← Pod 的 owner(ReplicaSet) → Deployment 名 + # __meta_kubernetes_pod_controller_name 是 + # Promtail 从 K8s API 直接拿的,任何集群都有, + # 不依赖任何第三方工具注入的标签 + # 4) pod name ← 最后兜底,保证 app 永不为空 + # + # 不使用 k8s.kuboard.cn/name 之类的平台私有标签:那只是 Kuboard 管理的集群才有, + # 换 Rancher/Lens/Helm/kubectl 部署的集群就不存在,会破坏通用性。 + - source_labels: + - __meta_kubernetes_pod_label_app_kubernetes_io_name + - __meta_kubernetes_pod_label_app + - __tmp_controller_name + - __meta_kubernetes_pod_name regex: ^;*([^;]+)(;.*)?$ + action: replace target_label: app - - separator: / - source_labels: [__meta_kubernetes_namespace, __meta_kubernetes_pod_label_app_kubernetes_io_name] - target_label: job - - source_labels: [__meta_kubernetes_pod_uid, __meta_kubernetes_pod_container_name] + # instance:app.kubernetes.io/instance → instance + - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_instance, __meta_kubernetes_pod_label_instance] + regex: ^;*([^;]+)(;.*)?$ + action: replace + target_label: instance + # component:app.kubernetes.io/component → component + - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_component, __meta_kubernetes_pod_label_component] + regex: ^;*([^;]+)(;.*)?$ + action: replace + target_label: component + - action: replace + source_labels: [__meta_kubernetes_pod_node_name] + target_label: node_name + - action: replace + source_labels: [__meta_kubernetes_namespace] + target_label: namespace + - action: replace + source_labels: [__meta_kubernetes_pod_name] + target_label: pod + - action: replace + source_labels: [__meta_kubernetes_pod_container_name] + target_label: container + # job: namespace/app(必须放在 app 之后,否则取到的是未回退的空值) + - action: replace + replacement: $1 separator: / - target_label: __path__ + source_labels: [namespace, app] + target_label: job + - action: replace replacement: /var/log/pods/*$1/*.log + separator: / + source_labels: [__meta_kubernetes_pod_uid, __meta_kubernetes_pod_container_name] + target_label: __path__ --- apiVersion: apps/v1 kind: DaemonSet @@ -343,7 +424,7 @@ spec: - name: containers hostPath: path: /var/lib/docker/containers -`, ns, ns, ns, lokiURL, extLabels, ns, promtailImage) +`, ns, ns, ns, lokiURL, extLabels, nsFilter, ns, promtailImage) } // FluentbitConfig Sidecar 模式 Fluent Bit 配置(采集 logPath,推送至 lokiHost:lokiPort) diff --git a/frontend/src/api/collector/index.ts b/frontend/src/api/collector/index.ts index 86d743f..e48096e 100644 --- a/frontend/src/api/collector/index.ts +++ b/frontend/src/api/collector/index.ts @@ -10,7 +10,7 @@ export const collectorApi = { create: (data: CreateCollectorRequest) => http.post('/collectors', data), - update: (id: number, data: { name: string; logPath: string }) => + update: (id: number, data: { name: string; logPath: string; targetNamespaces?: string[] }) => http.put<{ message: string }>(`/collectors/${id}`, data), remove: (id: number) => http.delete<{ message: string }>(`/collectors/${id}`), diff --git a/frontend/src/api/storage/index.ts b/frontend/src/api/storage/index.ts index 9073742..e30db0b 100644 --- a/frontend/src/api/storage/index.ts +++ b/frontend/src/api/storage/index.ts @@ -34,7 +34,9 @@ export const storageApi = { getDefault: () => http.get('/storages/default'), // 测试连接(不落库):可达性/凭证/桶存在性,createIfMissing 时自动建桶 + // id: 编辑态传入,accessKey/secretKey 留空时后端用库里保存的凭据兜底 test: (data: { + id?: number; storageType: StorageCreateInput['storageType']; endpoint?: string; bucket: string; diff --git a/frontend/src/pages/collectors/components/CollectorCard.tsx b/frontend/src/pages/collectors/components/CollectorCard.tsx index 1d736aa..8df6950 100644 --- a/frontend/src/pages/collectors/components/CollectorCard.tsx +++ b/frontend/src/pages/collectors/components/CollectorCard.tsx @@ -59,7 +59,7 @@ export const CollectorCard = memo(function CollectorCard({ message={collector.statusMessage || undefined} actions={ <> -