Files
opsx f87fb4b2b3
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s
refactor: 移除备份恢复功能(API/CLI/手册)
日志数据的保留与安全由对象存储 + Loki retention 承担,平台侧整库备份恢复价值有限:
删除 services/backup.go、/system/{backup,restore,restore/apply,backups} 路由与 handler、
CLI backup/restore 子命令、前端 api 方法与类型;自检所需的 dataDirPath/密钥指纹并入 selfcheck;
用户手册删备份章节并修正 FAQ 升级建议;CHANGELOG 记录移除。
2026-10-29 14:48:25 +08:00

298 lines
9.4 KiB
Go
Raw Permalink 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 (
"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]
}