package services import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "lokistack/config" "net/http" "os" "path/filepath" "strings" "syscall" "time" "lokistack/models" "lokistack/repository" ) // CheckItem 单条自检结果 type CheckItem struct { // Name 检查项名称(中文可读) Name string `json:"name"` // Status ok / warn / error Status string `json:"status"` // Message 一句话结论 Message string `json:"message,omitempty"` // Detail 补充信息(数量、路径、错误原文) Detail string `json:"detail,omitempty"` } // SelfCheckResult 一键诊断结果 type SelfCheckResult struct { CheckedAt time.Time `json:"checkedAt"` // Overall ok / warn / error —— 前端据此显示整体颜色 Overall string `json:"overall"` Items []CheckItem `json:"items"` } // SelfCheck 平台自检:把「能不能用」的判断集中到一处。 // // 设计目标:新人接手或故障时,先看这一个接口,不必在 5 个页面之间来回跳。 // 所有检查都必须有超时保护,不能让诊断本身把平台拖慢。 func SelfCheck() SelfCheckResult { res := SelfCheckResult{CheckedAt: time.Now(), Overall: "ok"} add := func(item CheckItem) { res.Items = append(res.Items, item) if item.Status == "error" { res.Overall = "error" } else if item.Status == "warn" && res.Overall == "ok" { res.Overall = "warn" } } db := repository.GetDB() // 1) 数据库 // // 注意:CLI 模式(lokistack self-check,CronJob/运维脚本用)不会 init DB, // repository.GetDB() 返回 nil。此时不能报"未初始化"——那是误报, // 改为直接解析数据文件统计,让 CLI 与 API 结论一致。 if db == nil { dbPath := databasePath() fi, err := os.Stat(dbPath) if err != nil { add(CheckItem{Name: "数据库", Status: "error", Message: "数据文件读不到", Detail: err.Error()}) } else { stats := fileDBStats(dbPath) status := "ok" if fi.Size() > 16<<20 { status = "warn" } add(CheckItem{Name: "数据库", Status: status, Message: fmt.Sprintf("%.1f KB(CLI 模式读取)", float64(fi.Size())/1024), Detail: stats}) } } else { dbPath := databasePath() fi, err := os.Stat(dbPath) if err != nil { add(CheckItem{Name: "数据库", Status: "error", Message: "数据文件读不到", Detail: err.Error()}) } else { db.RLock() counts := fmt.Sprintf("集群 %d / 主机 %d / 存储 %d / 部署 %d / 采集器 %d / 审计 %d", len(db.Clusters), len(db.Hosts), len(db.Storages), len(db.Deployments), len(db.Collectors), len(db.AuditLogs)) db.RUnlock() status := "ok" if fi.Size() > 16<<20 { status = "warn" } add(CheckItem{Name: "数据库", Status: status, Message: fmt.Sprintf("%.1f KB", float64(fi.Size())/1024), Detail: counts}) } } // 2) 加密密钥(丢了所有 kubeconfig/凭证都解不开) if fp := encryptionKeyFingerprint(); fp == "" { add(CheckItem{Name: "加密密钥", Status: "error", Message: "ENCRYPTION_KEY 未配置", Detail: "kubeconfig 与存储凭证依赖它解密;请确认 lokistack-secrets 中的 ENCRYPTION_KEY 已注入"}) } else { add(CheckItem{Name: "加密密钥", Status: "ok", Message: "已配置", Detail: "指纹 " + fp}) } // 3) 数据目录可用空间 if free, total, err := diskUsage(dataDirPath()); err == nil && total > 0 { pct := float64(free) / float64(total) * 100 status, msg := "ok", fmt.Sprintf("剩余 %.1f GB(%.0f%%)", float64(free)/(1<<30), pct) if pct < 10 { status = "error" } else if pct < 20 { status = "warn" } add(CheckItem{Name: "数据盘", Status: status, Message: msg}) } if db != nil { db.RLock() clusterList := db.Clusters hostList := db.Hosts deployList := db.Deployments collectorList := db.Collectors auditList := db.AuditLogs db.RUnlock() // 4) 集群接入(kubeconfig 能否解密) if len(clusterList) == 0 { add(CheckItem{Name: "K8s 集群", Status: "warn", Message: "尚未添加集群"}) } else { bad := 0 for i := range clusterList { if _, err := resolveKubeconfig(&clusterList[i]); err != nil { bad++ } } if bad > 0 { add(CheckItem{Name: "K8s 集群", Status: "error", Message: fmt.Sprintf("%d/%d 个集群的 kubeconfig 无法解密", bad, len(clusterList)), Detail: "多半是 ENCRYPTION_KEY 与写入时不一致"}) } else { add(CheckItem{Name: "K8s 集群", Status: "ok", Message: fmt.Sprintf("%d 个,kubeconfig 均可解密", len(clusterList))}) } } // 5) 主机可达性(用最近一次验证结果,不在这里做 SSH 连接,避免诊断变慢) if len(hostList) > 0 { errCount := 0 for _, h := range hostList { if h.Status == "error" { errCount++ } } if errCount > 0 { add(CheckItem{Name: "主机", Status: "warn", Message: fmt.Sprintf("%d/%d 台连接异常", errCount, len(hostList)), Detail: "到「主机管理」点「测试连接」查看具体原因"}) } else { add(CheckItem{Name: "主机", Status: "ok", Message: fmt.Sprintf("%d 台均正常", len(hostList))}) } } // 6) Loki 部署实例(真实探活 /ready) if len(deployList) == 0 { add(CheckItem{Name: "Loki 实例", Status: "warn", Message: "尚未部署 Loki"}) } else { var names []string bad := 0 for _, d := range deployList { status, err := probeDeploymentReady(&d) if err != nil || !status { bad++ names = append(names, d.Name) } } if bad > 0 { add(CheckItem{Name: "Loki 实例", Status: "error", Message: fmt.Sprintf("%d/%d 个实例未就绪", bad, len(deployList)), Detail: strings.Join(names, ", ")}) } else { add(CheckItem{Name: "Loki 实例", Status: "ok", Message: fmt.Sprintf("%d 个全部 ready", len(deployList))}) } } // 7) 采集器 if len(collectorList) > 0 { errCount, okCount := 0, 0 for _, c := range collectorList { switch c.Status { case "active", "running": okCount++ case "error": errCount++ } } if errCount > 0 { add(CheckItem{Name: "采集器", Status: "error", Message: fmt.Sprintf("%d 个异常", errCount), Detail: fmt.Sprintf("正常 %d 个;到「采集器」页看具体报错", okCount)}) } else { add(CheckItem{Name: "采集器", Status: "ok", Message: fmt.Sprintf("%d 个正常", okCount)}) } } // 8) 近期失败操作(审计) recentFail := 0 cutoff := time.Now().Add(-24 * time.Hour) for _, a := range auditList { if a.CreatedAt.After(cutoff) && a.Status >= 400 { recentFail++ } } if recentFail > 0 { add(CheckItem{Name: "近 24h 失败操作", Status: "warn", Message: fmt.Sprintf("%d 次", recentFail), Detail: "到「审计日志」筛选失败查看详情"}) } else { add(CheckItem{Name: "近 24h 失败操作", Status: "ok", Message: "无"}) } } return res } // probeDeploymentReady 探测某个 Loki 部署实例是否 /ready。 // // 不用 DeploymentReady():那个函数按"部署名 == 数据源名"匹配, // 而生成的 K8s 资源名固定是 loki,两者对不上会永远返回未就绪(已知问题)。 // 这里直接按部署记录里的 endpoint 探活,并对主机模式修正监听地址: // 主机 Docker 部署存的是 0.0.0.0:3100,从平台 Pod 侧要用主机真实 IP。 func probeDeploymentReady(d *models.LokiDeployment) (bool, error) { url := strings.TrimSuffix(strings.TrimSpace(d.Endpoint), "/") if url == "" { return false, fmt.Errorf("部署未记录访问地址") } if strings.Contains(url, "0.0.0.0") && d.Host != nil && d.Host.HostIP != "" { url = strings.Replace(url, "0.0.0.0", d.Host.HostIP, 1) } client := &http.Client{Timeout: 3 * time.Second} resp, err := client.Get(url + "/ready") if err != nil { return false, err } defer resp.Body.Close() buf := make([]byte, 128) n, _ := resp.Body.Read(buf) return resp.StatusCode == 200 && strings.Contains(strings.ToLower(string(buf[:n])), "ready"), nil } // fileDBStats 直接解析数据文件给出各集合记录数(CLI 模式用)。 func fileDBStats(path string) string { raw, err := os.ReadFile(path) if err != nil { return "" } var m map[string]json.RawMessage if err := json.Unmarshal(raw, &m); err != nil { return "数据文件不是合法 JSON" } count := func(key string) int { var arr []json.RawMessage if v, ok := m[key]; ok && json.Unmarshal(v, &arr) == nil { return len(arr) } return 0 } return fmt.Sprintf("集群 %d / 主机 %d / 存储 %d / 部署 %d / 采集器 %d / 审计 %d", count("clusters"), count("hosts"), count("storages"), count("deployments"), count("collectors"), count("auditLogs")) } // dataDirPath 数据目录绝对路径(自检磁盘空间等场景使用) func dataDirPath() string { return config.GetConfig().DataDir } // diskUsage 返回剩余与总空间(字节) func diskUsage(path string) (free, total uint64, err error) { var st syscall.Statfs_t if err := syscall.Statfs(path, &st); err != nil { return 0, 0, err } return st.Bavail * uint64(st.Bsize), st.Blocks * uint64(st.Bsize), nil } // encryptionKeyFingerprint ENCRYPTION_KEY 指纹(SHA256 前 16 位,自检展示用,不泄露密钥) func encryptionKeyFingerprint() string { key := config.GetConfig().EncryptionKey if key == "" { if b, err := os.ReadFile(filepath.Join(config.GetConfig().DataDir, ".encryption-key")); err == nil { key = strings.TrimSpace(string(b)) } } if key == "" { return "" } sum := sha256.Sum256([]byte(key)) return hex.EncodeToString(sum[:])[:16] }