Files
LokiStack/backend/services/collector.go
T
opsx c8d026adc7
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s
feat(collectors): DaemonSet 不再填日志路径,改为「只采集哪些命名空间」多选
用户指出的三个问题,逐条核实后的结论:

1) 「采集日志路径」对 DaemonSet 确实是多余的 —— 确认后端模板里写死了
   `replacement: /var/log/pods/*$1/*.log`(按 pod UID 精确匹配容器标准输出),
   表单里填的 logPath 对 DaemonSet 完全不参与渲染。
   → 选择 DaemonSet 时隐藏该字段,提交时强制置空。

2) 「可以指定采集多个命名空间」—— 后端**早就支持**:
   models.Collector.TargetNamespaces + promtailNamespaceFilter(),
   空数组 = 全集群。只是**前端新建弹框没暴露**(编辑弹框里有)。
   → 新建弹框补上多选 chips(全集群 / 各命名空间可点选),并补齐前后端透传:
     dto.CreateCollectorRequest.TargetNamespaces
     controller → services.CreateCollectorRequest.TargetNamespaces
     models.Collector.TargetNamespaces
   之前前端即使传了也会被 Go 静默丢弃(无该字段)。

3) 「命名空间」字段语义已在上一提交补了分类型说明:
   - DaemonSet:采集器自身部署在哪个命名空间(不影响采什么)
   - Sidecar:目标应用 Deployment 所在命名空间

新增 TestDaemonsetTargetNamespacesFilter:
  nil → 不生成 namespaces 白名单(全集群)
  ["astralx"] → 生成且包含 astralx
  ["astralx","default"] → 两个都在

=== 实测(d45dfa18ecc5)===
  前端文案「只采集这些命名空间」「不选 = 采集整个集群…」均已进包
  渲染测试 3 种输入全部符合预期
  注:本次 docker push latest 遇到 Docker Hub 502,v0.1 成功而 latest 仍指向旧镜像,
     导致首次滚动后 Pod 仍是旧代码;重推 latest + 强制重建 Pod 后确认
     Pod digest = d45dfa18ecc5 —— 记录这个坑:push 失败必须核对三方 digest。
2026-10-30 16:03:24 +00:00

606 lines
18 KiB
Go
Raw 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 (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"time"
"lokistack/models"
"lokistack/repository"
"lokistack/utils"
)
type CollectorService struct {
// opsMu 保护 opsInflight:同一采集器的安装/卸载互斥去重。
// 采集器是 fire-and-forget(接口立即返回),没有去重时连点「部署」
// 会对同一主机并发 docker rm -f + docker run,容器名冲突/配置互踩。
opsMu sync.Mutex
opsInflight map[uint]bool
}
func NewCollectorService() *CollectorService {
return &CollectorService{opsInflight: map[uint]bool{}}
}
// beginOp 声明开始一次安装/卸载;已在进行返回 false(直接忽略重复触发)
func (s *CollectorService) beginOp(id uint) bool {
s.opsMu.Lock()
defer s.opsMu.Unlock()
if s.opsInflight == nil {
s.opsInflight = map[uint]bool{}
}
if s.opsInflight[id] {
return false
}
s.opsInflight[id] = true
return true
}
func (s *CollectorService) endOp(id uint) {
s.opsMu.Lock()
delete(s.opsInflight, id)
s.opsMu.Unlock()
}
type CreateCollectorRequest struct {
Name string
Type string
LokiDeploymentID uint
HostID *uint
ClusterID *uint
TargetDeployment string
Namespace string
LogPath string
Labels string
ScrapeJobs []models.ScrapeJob
TargetNamespaces []string
}
// fillAssociations 填充关联的 Loki / Host / Cluster(用于配置生成与展示)
func (s *CollectorService) fillAssociations(c *models.Collector) {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Deployments {
if db.Deployments[i].ID == c.LokiDeploymentID {
d := db.Deployments[i]
c.LokiDeployment = &d
break
}
}
if c.HostID != nil {
for i := range db.Hosts {
if db.Hosts[i].ID == *c.HostID {
h := db.Hosts[i]
c.Host = &h
break
}
}
}
if c.ClusterID != nil {
for i := range db.Clusters {
if db.Clusters[i].ID == *c.ClusterID {
cl := db.Clusters[i]
c.Cluster = &cl
break
}
}
}
// 配置快照按当前字段实时渲染:DB 里存的 Config 只是上次安装时的副本,
// 用户改完采集项还没点「部署」时,看配置应该看到「将要生效的 YAML」。
// renderConfig 只读 c 的字段、不取锁,所以在 RLock 内调用是安全的。
if cfg, err := s.renderConfig(c); err == nil {
c.Config = cfg
}
}
// parseScrapeJobs 把接口层传来的 scrapeJobs([]interface{} / JSON 字符串)解析成模型。
// gin 的 map 绑定拿不到强类型,统一过一遍 JSON 转换,避免手写一堆类型断言。
func parseScrapeJobs(v interface{}) ([]models.ScrapeJob, error) {
if v == nil {
return nil, nil
}
raw, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("采集项格式不合法:%w", err)
}
var jobs []models.ScrapeJob
if err := json.Unmarshal(raw, &jobs); err != nil {
return nil, fmt.Errorf("采集项格式不合法:%w", err)
}
return jobs, nil
}
// renderConfig 按类型渲染当前配置(不落库)。
func (s *CollectorService) renderConfig(c *models.Collector) (string, error) {
endpoint := ""
if c.LokiDeployment != nil {
endpoint = c.LokiDeployment.Endpoint
}
clusterName := "host"
if c.Cluster != nil && c.Cluster.Name != "" {
clusterName = c.Cluster.Name
}
switch c.Type {
case "host-promtail":
// 主机名与 syslog 格式:优先用上次安装时探测到的真实值,
// 从未安装过时退回平台登记的主机名/IP(预览会提示这一点)。
hostname := c.ResolvedHostname
if hostname == "" && c.Host != nil {
hostname = c.Host.Name
}
if hostname == "" && c.Host != nil {
hostname = c.Host.HostIP
}
// 预览要与线上一致:这里也走一遍推送地址纠正(0.0.0.0 / 容器内回环)
pushEndpoint := endpoint
hostIP := ""
if c.Host != nil {
hostIP = c.Host.HostIP
}
if v, perr := hostPushEndpoint(endpoint, hostIP, c.RunMode == HostRunModeDocker); perr == nil {
pushEndpoint = v
}
return PromtailConfigForHost(HostPromtailOptions{
Endpoint: pushEndpoint,
Cluster: "host",
LabelsJSON: c.Labels,
Hostname: hostname,
SyslogFormat: c.SyslogFormat,
SystemLogPaths: c.SystemLogPaths,
LogPath: c.LogPath,
Jobs: c.ScrapeJobs,
})
case "k8s-daemonset":
ns := c.Namespace
if ns == "" && c.LokiDeployment != nil {
ns = c.LokiDeployment.Namespace
}
if ns == "" {
ns = "log"
}
return PromtailDaemonsetYAML(ns, endpoint, clusterName, c.Labels, c.TargetNamespaces, c.AppLabelSource, c.AppInclude), nil
case "k8s-fluentbit-sidecar":
host, port := parseLokiURL(endpoint)
app := c.TargetDeployment
ns := c.Namespace
if ns == "" {
ns = "default"
}
return FluentbitConfig(host, port, c.LogPath, app, ns), nil
}
return "", nil
}
// generateConfigSnapshot 按类型生成配置快照(渲染失败返回空串,不阻断保存)
func (s *CollectorService) generateConfigSnapshot(c *models.Collector) string {
cfg, err := s.renderConfig(c)
if err != nil {
return ""
}
return cfg
}
// PreviewConfig 按「当前已保存配置 + 本次提交的改动」渲染 YAML 预览。
// 只渲染不落库:用户在编辑器里点「预览」时用,避免为了看一眼 YAML 就改线上采集。
func (s *CollectorService) PreviewConfig(id uint, updates map[string]interface{}) (map[string]interface{}, error) {
c, err := s.Get(id)
if err != nil {
return nil, err
}
if v, ok := updates["logPath"].(string); ok {
c.LogPath = v
}
if v, ok := updates["labels"].(string); ok {
c.Labels = v
}
if v, ok := updates["scrapeJobs"]; ok {
jobs, perr := parseScrapeJobs(v)
if perr != nil {
return nil, perr
}
c.ScrapeJobs = jobs
}
if c.Type == "host-promtail" {
if err := models.ValidateScrapeJobs(c.ScrapeJobs); err != nil {
return nil, err
}
}
cfg, err := s.renderConfig(c)
if err != nil {
return nil, err
}
warnings := make([]string, 0, 2)
if c.Type == "host-promtail" {
if c.SyslogFormat == "" {
warnings = append(warnings, "尚未成功安装过:系统日志时间戳格式会在安装时按目标机实际格式探测(ISO8601 / 传统 syslog)")
}
if c.ResolvedHostname == "" {
warnings = append(warnings, "尚未成功安装过:host 标签将使用平台登记的主机名,实际安装时会替换为目标机真实 hostname")
}
// 推送地址填写了通配/回环地址时提前提示:这类地址采集器推不上去,
// 表现是「容器/systemd 都 running,但 Loki 里一条日志都没有」。
rawEndpoint := ""
if c.LokiDeployment != nil {
rawEndpoint = c.LokiDeployment.Endpoint
}
hostIP := ""
if c.Host != nil {
hostIP = c.Host.HostIP
}
if fixed, perr := hostPushEndpoint(rawEndpoint, hostIP, c.RunMode == HostRunModeDocker); perr == nil && fixed != rawEndpoint {
warnings = append(warnings, fmt.Sprintf("Loki 推送地址 %s 不可用,部署时会自动改为 %s(建议修正 Loki 部署的监听地址)", rawEndpoint, fixed))
}
}
return map[string]interface{}{"config": cfg, "warnings": warnings, "hostname": c.ResolvedHostname, "syslogFormat": c.SyslogFormat}, nil
}
func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collector, error) {
switch req.Type {
case "host-promtail", "k8s-daemonset", "k8s-fluentbit-sidecar":
default:
return nil, fmt.Errorf("不支持的采集器类型: %s", req.Type)
}
if req.LokiDeploymentID == 0 {
return nil, fmt.Errorf("请选择关联的 Loki 部署")
}
if req.Type == "host-promtail" && (req.HostID == nil || *req.HostID == 0) {
return nil, fmt.Errorf("主机采集器需要选择目标主机")
}
if (req.Type == "k8s-daemonset" || req.Type == "k8s-fluentbit-sidecar") && (req.ClusterID == nil || *req.ClusterID == 0) {
return nil, fmt.Errorf("Kubernetes 采集器需要选择目标集群")
}
if req.Type == "k8s-fluentbit-sidecar" && req.TargetDeployment == "" {
return nil, fmt.Errorf("Sidecar 采集器需要填写目标 Deployment 名称")
}
// 入库前统一校验采集项(含敏感路径拦截):Create 也要挡,
// 否则坏数据先落库、部署时才报错(Update 已有校验)
if err := models.ValidateScrapeJobs(req.ScrapeJobs); err != nil {
return nil, err
}
if strings.TrimSpace(req.LogPath) != "" {
if err := models.ValidateScrapeJobs([]models.ScrapeJob{{Name: "app", Paths: strings.Split(req.LogPath, ",")}}); err != nil {
return nil, err
}
}
refType := "cluster"
if req.Type == "host-promtail" {
refType = "host"
}
collector := models.Collector{
ID: repository.GetDB().NextID("collectors"),
Name: req.Name,
Type: req.Type,
LokiDeploymentID: req.LokiDeploymentID,
RefType: refType,
HostID: req.HostID,
ClusterID: req.ClusterID,
TargetDeployment: req.TargetDeployment,
Namespace: req.Namespace,
LogPath: req.LogPath,
Labels: req.Labels,
ScrapeJobs: req.ScrapeJobs,
TargetNamespaces: req.TargetNamespaces,
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
s.fillAssociations(&collector)
if collector.LokiDeployment == nil {
return nil, fmt.Errorf("关联的 Loki 部署不存在")
}
collector.Config = s.generateConfigSnapshot(&collector)
db := repository.GetDB()
db.Lock()
db.Collectors = append(db.Collectors, collector)
db.Unlock()
db.RequestSave()
go s.Install(collector.ID)
return &collector, nil
}
func (s *CollectorService) Get(id uint) (*models.Collector, error) {
db := repository.GetDB()
db.RLock()
var found *models.Collector
for i := range db.Collectors {
if db.Collectors[i].ID == id {
c := db.Collectors[i]
found = &c
break
}
}
db.RUnlock()
if found == nil {
return nil, utils.ErrNotFound
}
s.fillAssociations(found)
return found, nil
}
func (s *CollectorService) List(page, pageSize int) ([]models.Collector, int64, error) {
db := repository.GetDB()
db.RLock()
total := int64(len(db.Collectors))
start := (page - 1) * pageSize
if start < 0 || start >= len(db.Collectors) {
db.RUnlock()
return []models.Collector{}, total, nil
}
end := start + pageSize
if end > len(db.Collectors) {
end = len(db.Collectors)
}
result := make([]models.Collector, end-start)
copy(result, db.Collectors[start:end])
db.RUnlock()
for i := range result {
s.fillAssociations(&result[i])
}
return result, total, nil
}
func (s *CollectorService) Update(id uint, updates map[string]interface{}) error {
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Collectors {
if db.Collectors[i].ID == id {
if v, ok := updates["name"].(string); ok && v != "" {
db.Collectors[i].Name = v
}
if v, ok := updates["status"].(string); ok && v != "" {
db.Collectors[i].Status = v
}
if v, ok := updates["statusMessage"].(string); ok {
db.Collectors[i].StatusMessage = v
}
if v, ok := updates["logPath"].(string); ok {
if v != db.Collectors[i].LogPath {
db.Collectors[i].StatusMessage = "采集路径已保存,点击部署以应用新配置"
}
db.Collectors[i].LogPath = v
}
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
}
// appLabelSource:app 标签取值来源(空=默认 controller_name 回退链)
if v, ok := updates["appLabelSource"].(string); ok {
if strings.TrimSpace(v) != db.Collectors[i].AppLabelSource {
db.Collectors[i].StatusMessage = "app 标签来源已保存,点击部署以应用新配置"
}
db.Collectors[i].AppLabelSource = strings.TrimSpace(v)
}
// appInclude:只采集这些 app(白名单,空=不限)
if v, ok := updates["appInclude"]; ok {
var apps []string
switch t := v.(type) {
case []interface{}:
for _, item := range t {
if s, ok := item.(string); ok {
if s = strings.TrimSpace(s); s != "" {
apps = append(apps, s)
}
}
}
case []string:
for _, s := range t {
if s = strings.TrimSpace(s); s != "" {
apps = append(apps, s)
}
}
case string:
for _, s := range strings.Split(t, ",") {
if s = strings.TrimSpace(s); s != "" {
apps = append(apps, s)
}
}
}
if len(apps) != len(db.Collectors[i].AppInclude) {
db.Collectors[i].StatusMessage = "采集 app 白名单已保存,点击部署以应用新配置"
}
db.Collectors[i].AppInclude = apps
}
// scrapeJobs:主机模式的结构化采集项(B 档)
if v, ok := updates["scrapeJobs"]; ok {
jobs, jerr := parseScrapeJobs(v)
if jerr != nil {
return jerr
}
if verr := models.ValidateScrapeJobs(jobs); verr != nil {
return verr
}
if len(jobs) != len(db.Collectors[i].ScrapeJobs) {
db.Collectors[i].StatusMessage = "采集项已保存,点击部署以应用新配置"
}
db.Collectors[i].ScrapeJobs = jobs
}
// 以下三个字段由安装流程写回(探测结果 / 渲染快照),不在前端表单里
if v, ok := updates["resolvedHostname"].(string); ok && v != "" {
db.Collectors[i].ResolvedHostname = v
}
if v, ok := updates["syslogFormat"].(string); ok && v != "" {
db.Collectors[i].SyslogFormat = v
}
// 系统日志路径也是探测结果(Ubuntu 与 RHEL 系不同),
// 不落库的话「配置预览」会和线上实际采集路径不一致。
if v, ok := updates["systemLogPaths"].([]string); ok && len(v) > 0 {
db.Collectors[i].SystemLogPaths = v
}
// runMode:安装时探测出的托管方式(docker | systemd),卸载后清空。
if v, ok := updates["runMode"].(string); ok {
db.Collectors[i].RunMode = v
}
if v, ok := updates["config"].(string); ok && v != "" {
db.Collectors[i].Config = v
}
db.Collectors[i].UpdatedAt = time.Now()
db.RequestSave()
return nil
}
}
return utils.ErrNotFound
}
// UninstallCollectorsForDeployment 卸载并删除某部署下的所有采集器。
// 卸载失败时保留记录,避免远端资源失去跟踪。
func UninstallCollectorsForDeployment(ctx context.Context, deploymentID uint) {
db := repository.GetDB()
db.RLock()
var targets []models.Collector
for _, c := range db.Collectors {
if c.LokiDeploymentID == deploymentID {
targets = append(targets, c)
}
}
db.RUnlock()
for i := range targets {
c := targets[i]
if err := NewCollectorDeployer(&c, c.LokiDeployment, nil).Uninstall(ctx); err != nil {
log.Printf("[deploy-delete] 采集器 %q 卸载失败,保留记录: %v", c.Name, err)
continue
}
db.Lock()
for j := range db.Collectors {
if db.Collectors[j].ID == c.ID {
db.Collectors = append(db.Collectors[:j], db.Collectors[j+1:]...)
break
}
}
db.RequestSave()
db.Unlock()
}
}
func (s *CollectorService) Delete(id uint) error {
collector, err := s.Get(id)
if err != nil {
return utils.ErrNotFound
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
// 先卸载远端资源,成功后再删记录;卸载失败直接返回,避免「界面删了、远端还在」
if err := NewCollectorDeployer(collector, collector.LokiDeployment, nil).Uninstall(ctx); err != nil {
return fmt.Errorf("卸载采集器失败,未删除记录: %w", err)
}
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i, c := range db.Collectors {
if c.ID == id {
db.Collectors = append(db.Collectors[:i], db.Collectors[i+1:]...)
db.RequestSave()
return nil
}
}
return utils.ErrNotFound
}
// Install 异步部署采集器
func (s *CollectorService) Install(id uint) {
if !s.beginOp(id) {
return // 同一采集器的安装/卸载正在进行,忽略重复触发
}
defer s.endOp(id)
collector, err := s.Get(id)
if err != nil {
return
}
if collector.LokiDeployment == nil {
s.Update(id, map[string]interface{}{"status": "error", "statusMessage": "关联的 Loki 不存在"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := NewCollectorDeployer(collector, collector.LokiDeployment, nil).Install(ctx); err != nil {
s.Update(id, map[string]interface{}{"status": "error", "statusMessage": err.Error()})
return
}
// 安装时探测到的真实 hostname / syslog 格式 + 实际生效的配置都要落库,
// 否则「配置预览」与线上真实配置不一致(用户没法核对)。
updates := map[string]interface{}{"status": "active", "statusMessage": "采集器已部署"}
if collector.Config != "" {
updates["config"] = collector.Config
}
if collector.ResolvedHostname != "" {
updates["resolvedHostname"] = collector.ResolvedHostname
}
if collector.SyslogFormat != "" {
updates["syslogFormat"] = collector.SyslogFormat
}
if len(collector.SystemLogPaths) > 0 {
updates["systemLogPaths"] = collector.SystemLogPaths
}
// 托管方式(docker | systemd)由安装流程探测后写回,前端据此展示运行方式
updates["runMode"] = collector.RunMode
s.Update(id, updates)
}
// Uninstall 异步卸载采集器
func (s *CollectorService) Uninstall(id uint) {
if !s.beginOp(id) {
return
}
defer s.endOp(id)
collector, err := s.Get(id)
if err != nil {
return
}
if collector.LokiDeployment == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
NewCollectorDeployer(collector, collector.LokiDeployment, nil).Uninstall(ctx)
s.Update(id, map[string]interface{}{"status": "pending", "statusMessage": "采集器已卸载", "runMode": ""})
}