日志数据的保留与安全由对象存储 + Loki retention 承担,平台侧整库备份恢复价值有限:
删除 services/backup.go、/system/{backup,restore,restore/apply,backups} 路由与 handler、
CLI backup/restore 子命令、前端 api 方法与类型;自检所需的 dataDirPath/密钥指纹并入 selfcheck;
用户手册删备份章节并修正 FAQ 升级建议;CHANGELOG 记录移除。
298 lines
9.4 KiB
Go
298 lines
9.4 KiB
Go
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]
|
||
}
|