refactor: 移除备份恢复功能(API/CLI/手册)
日志数据的保留与安全由对象存储 + Loki retention 承担,平台侧整库备份恢复价值有限:
删除 services/backup.go、/system/{backup,restore,restore/apply,backups} 路由与 handler、
CLI backup/restore 子命令、前端 api 方法与类型;自检所需的 dataDirPath/密钥指纹并入 selfcheck;
用户手册删备份章节并修正 FAQ 升级建议;CHANGELOG 记录移除。
This commit is contained in:
1 parent
e07fe55c86
commit
f87fb4b2b3
11 files changed
+34
-552
No files matched your search
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 移除
|
||||||
|
- 备份恢复功能(含 API / CLI / 手册):日志数据本体的保留与安全由对象存储与 Loki retention 承担,平台侧备份价值有限
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
- 落地页(homepage/,`/homepage` 访问):产品介绍、特性、快速开始
|
- 落地页(homepage/,`/homepage` 访问):产品介绍、特性、快速开始
|
||||||
- 用户手册文档站(docsify,`/docs` 访问):安装、部署向导、采集、查询、分析、告警、授权、备份、FAQ 十篇
|
- 用户手册文档站(docsify,`/docs` 访问):安装、部署向导、采集、查询、分析、告警、授权、备份、FAQ 十篇
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"lokistack/pkg/metrics"
|
"lokistack/pkg/metrics"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -19,71 +17,8 @@ func NewSystemController() *SystemController { return &SystemController{} }
|
|||||||
// Backup 导出当前平台数据(tar.gz 下载)。
|
// Backup 导出当前平台数据(tar.gz 下载)。
|
||||||
//
|
//
|
||||||
// 内容:数据文件 + kubeconfigs/ + known_hosts/ + manifest.json(含密钥指纹),
|
// 内容:数据文件 + kubeconfigs/ + known_hosts/ + manifest.json(含密钥指纹),
|
||||||
// 不含明文密钥。恢复必须使用同一把 ENCRYPTION_KEY。
|
|
||||||
func (ctrl *SystemController) Backup(c *gin.Context) {
|
|
||||||
name := fmt.Sprintf("lokistack-backup-%s.tar.gz", time.Now().Format("20060102-150405"))
|
|
||||||
c.Header("Content-Type", "application/gzip")
|
|
||||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
|
|
||||||
|
|
||||||
if _, err := services.CreateBackup(c.Writer); err != nil {
|
|
||||||
// 已经写了响应头,无法再改状态码,只能把错误写进 body 尾部
|
|
||||||
_, _ = c.Writer.WriteString(fmt.Sprintf("\n\n# 备份失败: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore 上传备份包:校验 + 暂存,**不直接覆盖**线上数据库。
|
// Restore 上传备份包:校验 + 暂存,**不直接覆盖**线上数据库。
|
||||||
// 返回暂存路径与密钥指纹比对结果,由管理员确认后再调用 RestoreApply。
|
|
||||||
func (ctrl *SystemController) Restore(c *gin.Context) {
|
|
||||||
file, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequest(c, "请上传备份文件(form-data 字段名 file)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if file.Size > 128<<20 {
|
|
||||||
utils.BadRequest(c, "备份文件超过 128MB,请用 run.sh restore 在服务器上操作")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f, err := file.Open()
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalServerError(c, fmt.Sprintf("读取上传文件失败: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
res, err := services.ValidateAndStageBackup(f)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequest(c, fmt.Sprintf("备份校验失败: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
utils.Success(c, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RestoreApply 应用已暂存的备份。应用后需重启平台进程才生效。
|
|
||||||
func (ctrl *SystemController) RestoreApply(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Confirm bool `json:"confirm"`
|
|
||||||
}
|
|
||||||
_ = c.ShouldBindJSON(&req)
|
|
||||||
if !req.Confirm {
|
|
||||||
utils.BadRequest(c, "恢复会覆盖当前全部数据,请传 confirm=true 确认")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
path, err := services.ApplyStagedBackup()
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequest(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
utils.Success(c, gin.H{
|
|
||||||
"message": "备份已应用,请重启平台使新数据生效(kubectl -n log rollout restart deploy/lokistack)",
|
|
||||||
"dbPath": path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backups 列出服务器上已有的备份文件(run.sh backup 产出)。
|
|
||||||
func (ctrl *SystemController) Backups(c *gin.Context) {
|
|
||||||
utils.Success(c, gin.H{"list": services.ListBackups()})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRetention 读取平台数据保留期(告警发送历史 / 审计日志)。
|
// GetRetention 读取平台数据保留期(告警发送历史 / 审计日志)。
|
||||||
func (ctrl *SystemController) GetRetention(c *gin.Context) {
|
func (ctrl *SystemController) GetRetention(c *gin.Context) {
|
||||||
|
|||||||
+4
-61
@@ -39,79 +39,22 @@ func fileExists(path string) bool {
|
|||||||
|
|
||||||
// runCLI 提供不依赖 Web 的运维命令(备份 CronJob、恢复、自检都走它)。
|
// runCLI 提供不依赖 Web 的运维命令(备份 CronJob、恢复、自检都走它)。
|
||||||
//
|
//
|
||||||
// lokistack backup --output /app/data/backups/xxx.tar.gz
|
|
||||||
// lokistack restore <备份包路径> # 校验 + 立即应用(会先备份当前 DB)
|
|
||||||
// lokistack self-check # 打印 JSON 自检结果
|
// lokistack self-check # 打印 JSON 自检结果
|
||||||
func runCLI(args []string) {
|
func runCLI(args []string) {
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "backup":
|
|
||||||
out := ""
|
|
||||||
for i := 1; i < len(args); i++ {
|
|
||||||
if args[i] == "--output" && i+1 < len(args) {
|
|
||||||
out = args[i+1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if out == "" {
|
|
||||||
out = "/app/data/backups/lokistack-" + time.Now().Format("20060102-150405") + ".tar.gz"
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(out), 0o700); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "创建备份目录失败: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
f, err := os.Create(out)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "创建备份文件失败: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
m, err := services.CreateBackup(f)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "备份失败: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Printf("备份完成: %s\n记录数: %d\n密钥指纹: %s\n", out, m.RecordCount, m.KeyFP)
|
|
||||||
|
|
||||||
case "restore":
|
|
||||||
if len(args) < 2 {
|
|
||||||
fmt.Fprintln(os.Stderr, "用法: lokistack restore <备份包.tar.gz>")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
f, err := os.Open(args[1])
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "打开备份失败: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
res, err := services.ValidateAndStageBackup(f)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "校验失败: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if res.Warning != "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "⚠️ %s\n", res.Warning)
|
|
||||||
}
|
|
||||||
path, err := services.ApplyStagedBackup()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "应用失败: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Printf("恢复完成: %s(备份 %d 条记录)\n请重启平台进程使新数据生效。\n", path, res.RowsInBackup)
|
|
||||||
|
|
||||||
case "self-check":
|
case "self-check":
|
||||||
b, _ := json.MarshalIndent(services.SelfCheck(), "", " ")
|
out, _ := json.MarshalIndent(services.SelfCheck(), "", " ")
|
||||||
fmt.Println(string(b))
|
fmt.Println(string(out))
|
||||||
|
|
||||||
case "version":
|
case "version":
|
||||||
fmt.Printf("%s (commit %s, built %s)\n", version, gitCommit, buildTime)
|
fmt.Printf("%s (commit %s, built %s)\n", version, gitCommit, buildTime)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "未知命令 %q。可用: backup / restore / self-check / version\n", args[0])
|
fmt.Fprintf(os.Stderr, "未知命令 %q。可用: self-check / version\n", args[0])
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// 运维命令行:支持在容器/CronJob 里不经 Web 完成备份/恢复/自检
|
// 运维命令行:容器/CronJob 里不经 Web 完成自检与版本查询
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
runCLI(os.Args[1:])
|
runCLI(os.Args[1:])
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -147,10 +147,6 @@ func SetupRouter() *gin.Engine {
|
|||||||
system := authorized.Group("/system")
|
system := authorized.Group("/system")
|
||||||
system.Use(middleware.AdminAuth())
|
system.Use(middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
system.GET("/backup", systemCtrl.Backup)
|
|
||||||
system.POST("/restore", systemCtrl.Restore)
|
|
||||||
system.POST("/restore/apply", systemCtrl.RestoreApply)
|
|
||||||
system.GET("/backups", systemCtrl.Backups)
|
|
||||||
system.GET("/self-check", systemCtrl.Health)
|
system.GET("/self-check", systemCtrl.Health)
|
||||||
system.GET("/metrics-summary", middleware.AdminAuth(), systemCtrl.MetricsSummary)
|
system.GET("/metrics-summary", middleware.AdminAuth(), systemCtrl.MetricsSummary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,364 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/tar"
|
|
||||||
"compress/gzip"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"lokistack/repository"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 备份/恢复设计要点
|
|
||||||
//
|
|
||||||
// 为什么必须有:平台的全部状态(部署/采集/存储/集群/主机/策略/告警/用户/审计)
|
|
||||||
// 都在这一个 JSON 文件里;kubeconfig 虽然加密存在 DB 内,但解密依赖
|
|
||||||
// ENCRYPTION_KEY(K8s Secret)。DB 丢了 = 平台失去"这些资源是谁建的"的映射,
|
|
||||||
// 从此无法管理/卸载/升级正在跑的 Loki 与采集器。
|
|
||||||
//
|
|
||||||
// 安全取舍:
|
|
||||||
// - 备份包内含加密后的 kubeconfig 与 known_hosts,**不含明文密钥**
|
|
||||||
// - manifest 里只记录 ENCRYPTION_KEY 的指纹(sha256 前 16 位),
|
|
||||||
// 用于恢复时校验"这把钥匙能不能解开这份备份",不泄露密钥本身
|
|
||||||
// - 恢复分两步(校验暂存 → 手动应用),避免一个误点就覆盖线上 DB
|
|
||||||
|
|
||||||
// BackupManifest 备份包元信息(放在包内 manifest.json)
|
|
||||||
type BackupManifest struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
DBBytes int64 `json:"dbBytes"`
|
|
||||||
DBSHA256 string `json:"dbSha256"`
|
|
||||||
KeyFP string `json:"encryptionKeyFingerprint"`
|
|
||||||
RecordCount int `json:"recordCount"`
|
|
||||||
Note string `json:"note"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// backupExclude 备份时跳过的文件/目录(临时文件、lost+found、正在写的 restore 暂存)
|
|
||||||
var backupExclude = map[string]bool{
|
|
||||||
"lost+found": true, ".restore-pending": true, "backups": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateBackup 把 /app/data 打包成 tar.gz 写入 w。
|
|
||||||
func CreateBackup(w io.Writer) (*BackupManifest, error) {
|
|
||||||
dataDir := dataDirPath()
|
|
||||||
dbPath := databasePath()
|
|
||||||
|
|
||||||
dbInfo, err := os.Stat(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("读取数据库失败: %w", err)
|
|
||||||
}
|
|
||||||
dbBytes, err := os.ReadFile(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("读取数据库失败: %w", err)
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256(dbBytes)
|
|
||||||
|
|
||||||
// 记录数从**备份出来的字节**里数,不用 repository.GetDB():
|
|
||||||
// CLI 模式(lokistack backup,跑在 CronJob 里)根本没初始化 DB,
|
|
||||||
// 用 GetDB() 会得到 nil,manifest 里的 recordCount 恒为 0,
|
|
||||||
// 恢复时就看不出"这份备份里有没有东西"。
|
|
||||||
recordCount := countRecords(dbBytes)
|
|
||||||
|
|
||||||
manifest := &BackupManifest{
|
|
||||||
Version: "2.0.0",
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
DBBytes: dbInfo.Size(),
|
|
||||||
DBSHA256: hex.EncodeToString(sum[:]),
|
|
||||||
KeyFP: encryptionKeyFingerprint(),
|
|
||||||
RecordCount: recordCount,
|
|
||||||
Note: "包内 kubeconfig 为加密态;恢复必须使用同一把 ENCRYPTION_KEY,否则 kubeconfig 无法解密",
|
|
||||||
}
|
|
||||||
|
|
||||||
gz := gzip.NewWriter(w)
|
|
||||||
defer gz.Close()
|
|
||||||
tw := tar.NewWriter(gz)
|
|
||||||
defer tw.Close()
|
|
||||||
|
|
||||||
// 1) manifest
|
|
||||||
mBytes, _ := json.MarshalIndent(manifest, "", " ")
|
|
||||||
if err := writeTarFile(tw, "manifest.json", mBytes, 0o600); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) data 目录下所有文件(DB + kubeconfigs/ + known_hosts/)
|
|
||||||
err = filepath.Walk(dataDir, func(path string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return nil // 单个文件读不到不阻断整包
|
|
||||||
}
|
|
||||||
if path == dataDir {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rel, err := filepath.Rel(dataDir, path)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
top := strings.SplitN(rel, string(os.PathSeparator), 2)[0]
|
|
||||||
if backupExclude[top] {
|
|
||||||
if info.IsDir() {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if info.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// 跳过正在写的临时文件
|
|
||||||
if strings.HasPrefix(filepath.Base(path), ".lokistack-") && strings.HasSuffix(path, ".tmp") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
hdr := &tar.Header{
|
|
||||||
Name: filepath.ToSlash(filepath.Join("data", rel)),
|
|
||||||
Mode: int64(info.Mode().Perm()),
|
|
||||||
Size: info.Size(),
|
|
||||||
ModTime: info.ModTime(),
|
|
||||||
}
|
|
||||||
if err := tw.WriteHeader(hdr); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = io.Copy(tw, f)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return manifest, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeTarFile(tw *tar.Writer, name string, data []byte, mode int64) error {
|
|
||||||
hdr := &tar.Header{Name: name, Mode: mode, Size: int64(len(data)), ModTime: time.Now()}
|
|
||||||
if err := tw.WriteHeader(hdr); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err := tw.Write(data)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// RestoreResult 校验结果
|
|
||||||
type RestoreResult struct {
|
|
||||||
Manifest *BackupManifest `json:"manifest"`
|
|
||||||
KeyMatches bool `json:"keyMatches"`
|
|
||||||
RowsInBackup int `json:"rowsInBackup"`
|
|
||||||
CurrentRows int `json:"currentRows"`
|
|
||||||
StagedPath string `json:"stagedPath,omitempty"`
|
|
||||||
Warning string `json:"warning,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateAndStageBackup 校验备份包并暂存到 <dataDir>/.restore-pending/。
|
|
||||||
//
|
|
||||||
// 不在这一步直接覆盖线上 DB:恢复是不可逆操作,必须让人再确认一次
|
|
||||||
// (页面上的「应用恢复」或 run.sh restore)。
|
|
||||||
func ValidateAndStageBackup(r io.Reader) (*RestoreResult, error) {
|
|
||||||
gr, err := gzip.NewReader(r)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("不是合法的 gzip 文件: %w", err)
|
|
||||||
}
|
|
||||||
defer gr.Close()
|
|
||||||
tr := tar.NewReader(gr)
|
|
||||||
|
|
||||||
var (
|
|
||||||
manifest *BackupManifest
|
|
||||||
dbBytes []byte
|
|
||||||
)
|
|
||||||
for {
|
|
||||||
hdr, err := tr.Next()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("解包失败: %w", err)
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case hdr.Name == "manifest.json":
|
|
||||||
b, _ := io.ReadAll(io.LimitReader(tr, 1<<20))
|
|
||||||
var m BackupManifest
|
|
||||||
if err := json.Unmarshal(b, &m); err == nil {
|
|
||||||
manifest = &m
|
|
||||||
}
|
|
||||||
case hdr.Name == "data/opsxloki.json":
|
|
||||||
dbBytes, _ = io.ReadAll(io.LimitReader(tr, 64<<20))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if manifest == nil {
|
|
||||||
return nil, fmt.Errorf("备份包缺少 manifest.json,不是本平台导出的备份")
|
|
||||||
}
|
|
||||||
if len(dbBytes) == 0 {
|
|
||||||
return nil, fmt.Errorf("备份包内没有 data/opsxloki.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 校验 DB 完整性:必须是合法 JSON 且能解析出预期集合
|
|
||||||
var probe map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(dbBytes, &probe); err != nil {
|
|
||||||
return nil, fmt.Errorf("备份内的数据库不是合法 JSON: %w", err)
|
|
||||||
}
|
|
||||||
for _, required := range []string{"users", "deployments", "collectors"} {
|
|
||||||
if _, ok := probe[required]; !ok {
|
|
||||||
return nil, fmt.Errorf("备份内数据库缺少集合 %q,可能不是 LokiStack 的备份", required)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 哈希校验(防止传输损坏)
|
|
||||||
sum := sha256.Sum256(dbBytes)
|
|
||||||
if manifest.DBSHA256 != "" && hex.EncodeToString(sum[:]) != manifest.DBSHA256 {
|
|
||||||
return nil, fmt.Errorf("备份内数据库校验失败(sha256 不匹配),文件可能已损坏")
|
|
||||||
}
|
|
||||||
|
|
||||||
stagedDir := filepath.Join(dataDirPath(), ".restore-pending")
|
|
||||||
if err := os.MkdirAll(stagedDir, 0o700); err != nil {
|
|
||||||
return nil, fmt.Errorf("创建暂存目录失败: %w", err)
|
|
||||||
}
|
|
||||||
stagedPath := filepath.Join(stagedDir, "opsxloki.json")
|
|
||||||
if err := os.WriteFile(stagedPath, dbBytes, 0o600); err != nil {
|
|
||||||
return nil, fmt.Errorf("写入暂存文件失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentRows := 0
|
|
||||||
if db := repository.GetDB(); db != nil {
|
|
||||||
db.RLock()
|
|
||||||
currentRows = len(db.Deployments) + len(db.Collectors) + len(db.Clusters) + len(db.Hosts)
|
|
||||||
db.RUnlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
res := &RestoreResult{
|
|
||||||
Manifest: manifest,
|
|
||||||
KeyMatches: manifest.KeyFP == "" || manifest.KeyFP == encryptionKeyFingerprint(),
|
|
||||||
RowsInBackup: manifest.RecordCount,
|
|
||||||
CurrentRows: currentRows,
|
|
||||||
StagedPath: stagedPath,
|
|
||||||
}
|
|
||||||
if !res.KeyMatches {
|
|
||||||
res.Warning = "备份的 ENCRYPTION_KEY 指纹与当前平台不一致:恢复后 kubeconfig / 存储凭证将无法解密," +
|
|
||||||
"需要用备份时的那把 ENCRYPTION_KEY 覆盖 lokistack-secrets 中的值后再启动"
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// countRecords 从数据库 JSON 字节里统计各集合的记录总数。
|
|
||||||
func countRecords(dbBytes []byte) int {
|
|
||||||
var raw map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(dbBytes, &raw); err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
total := 0
|
|
||||||
for _, v := range raw {
|
|
||||||
var arr []json.RawMessage
|
|
||||||
if err := json.Unmarshal(v, &arr); err == nil {
|
|
||||||
total += len(arr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
// encryptionKeyFingerprint 返回当前 ENCRYPTION_KEY 的指纹(sha256 前 16 位),不泄露密钥。
|
|
||||||
func encryptionKeyFingerprint() string {
|
|
||||||
key := os.Getenv("ENCRYPTION_KEY")
|
|
||||||
if key == "" {
|
|
||||||
// 兼容从文件读取的部署方式
|
|
||||||
if p := os.Getenv("ENCRYPTION_KEY_FILE"); p != "" {
|
|
||||||
if b, err := os.ReadFile(p); err == nil {
|
|
||||||
key = strings.TrimSpace(string(b))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if key == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256([]byte(key))
|
|
||||||
return hex.EncodeToString(sum[:])[:16]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyStagedBackup 把暂存文件应用为正式 DB(调用方负责重启进程)。
|
|
||||||
func ApplyStagedBackup() (string, error) {
|
|
||||||
restoreDir := filepath.Join(dataDirPath(), ".restore-pending")
|
|
||||||
staged := filepath.Join(restoreDir, "lokistack.json")
|
|
||||||
if _, err := os.Stat(staged); err != nil {
|
|
||||||
// 兼容旧版暂存文件名
|
|
||||||
staged = filepath.Join(restoreDir, "opsxloki.json")
|
|
||||||
}
|
|
||||||
b, err := os.ReadFile(staged)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("没有待应用的恢复文件: %w", err)
|
|
||||||
}
|
|
||||||
var probe map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(b, &probe); err != nil {
|
|
||||||
return "", fmt.Errorf("暂存文件已损坏: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dbPath := databasePath()
|
|
||||||
// 先把当前 DB 备份一份,恢复错了还能退回去
|
|
||||||
if cur, err := os.ReadFile(dbPath); err == nil {
|
|
||||||
rollback := dbPath + ".before-restore." + time.Now().Format("20060102-150405")
|
|
||||||
_ = os.WriteFile(rollback, cur, 0o600)
|
|
||||||
}
|
|
||||||
tmp := dbPath + ".restoring"
|
|
||||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmp, dbPath); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
_ = os.Remove(staged)
|
|
||||||
// 关键:立即把内存态切换为恢复后的数据。否则「替换文件 → 重启」之间,
|
|
||||||
// 任何一次防抖落盘都会用旧内存态把恢复文件覆盖回去,恢复静默失效。
|
|
||||||
if db := repository.GetDB(); db != nil {
|
|
||||||
db.Lock()
|
|
||||||
if err := db.Reload(); err != nil {
|
|
||||||
db.Unlock()
|
|
||||||
return dbPath, fmt.Errorf("恢复文件已生效但内存重载失败(%v),请立即重启进程", err)
|
|
||||||
}
|
|
||||||
db.Unlock()
|
|
||||||
}
|
|
||||||
return dbPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListBackups 列出 dataDir/backups 下已有的备份文件(run.sh backup 产出)。
|
|
||||||
func ListBackups() []map[string]interface{} {
|
|
||||||
dir := filepath.Join(dataDirPath(), "backups")
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
return []map[string]interface{}{}
|
|
||||||
}
|
|
||||||
type item struct {
|
|
||||||
name string
|
|
||||||
size int64
|
|
||||||
mod time.Time
|
|
||||||
}
|
|
||||||
var items []item
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".tar.gz") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info, err := e.Info()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items = append(items, item{e.Name(), info.Size(), info.ModTime()})
|
|
||||||
}
|
|
||||||
sort.Slice(items, func(i, j int) bool { return items[i].mod.After(items[j].mod) })
|
|
||||||
out := make([]map[string]interface{}, 0, len(items))
|
|
||||||
for _, it := range items {
|
|
||||||
out = append(out, map[string]interface{}{
|
|
||||||
"name": it.name, "sizeBytes": it.size, "createdAt": it.mod,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func dataDirPath() string {
|
|
||||||
if p := os.Getenv("DATA_DIR"); p != "" {
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
return "/app/data"
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"lokistack/config"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -263,6 +267,11 @@ func fileDBStats(path string) string {
|
|||||||
count("deployments"), count("collectors"), count("auditLogs"))
|
count("deployments"), count("collectors"), count("auditLogs"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dataDirPath 数据目录绝对路径(自检磁盘空间等场景使用)
|
||||||
|
func dataDirPath() string {
|
||||||
|
return config.GetConfig().DataDir
|
||||||
|
}
|
||||||
|
|
||||||
// diskUsage 返回剩余与总空间(字节)
|
// diskUsage 返回剩余与总空间(字节)
|
||||||
func diskUsage(path string) (free, total uint64, err error) {
|
func diskUsage(path string) (free, total uint64, err error) {
|
||||||
var st syscall.Statfs_t
|
var st syscall.Statfs_t
|
||||||
@@ -271,3 +280,18 @@ func diskUsage(path string) (free, total uint64, err error) {
|
|||||||
}
|
}
|
||||||
return st.Bavail * uint64(st.Bsize), st.Blocks * uint64(st.Bsize), nil
|
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]
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ LokiStack 把 Grafana Loki 日志聚合的部署、采集、查询、分析与
|
|||||||
- **采集日志**:Promtail / Fluent Bit 采集策略可视化配置,命名空间多选、app 标签来源四级回退、敏感路径拦截。
|
- **采集日志**:Promtail / Fluent Bit 采集策略可视化配置,命名空间多选、app 标签来源四级回退、敏感路径拦截。
|
||||||
- **查询与分析**:LogQL 高亮补全、快捷查询、Live Tail;分析页自动聚合日志量、错误与 Ingress 访问指标。
|
- **查询与分析**:LogQL 高亮补全、快捷查询、Live Tail;分析页自动聚合日志量、错误与 Ingress 访问指标。
|
||||||
- **告警**:内置九条应用日志规则,绑定默认通道后填入飞书 Webhook 即可接收;支持冷却与风暴抑制。
|
- **告警**:内置九条应用日志规则,绑定默认通道后填入飞书 Webhook 即可接收;支持冷却与风暴抑制。
|
||||||
- **运维底座**:凭证加密存储、操作审计、备份恢复、Prometheus 自监控指标。
|
- **运维底座**:凭证加密存储、操作审计、Prometheus 自监控指标。
|
||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,4 @@
|
|||||||
- [日志分析](analysis.md)
|
- [日志分析](analysis.md)
|
||||||
- [告警通知](alerts.md)
|
- [告警通知](alerts.md)
|
||||||
- [查询授权](access.md)
|
- [查询授权](access.md)
|
||||||
- [备份与恢复](backup.md)
|
|
||||||
- [常见问题](faq.md)
|
- [常见问题](faq.md)
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# 备份与恢复
|
|
||||||
|
|
||||||
平台状态集中在单个 JSON 文件(`$DATA_DIR/lokistack.json`),备份恢复围绕它展开。
|
|
||||||
|
|
||||||
## 备份
|
|
||||||
|
|
||||||
- 手动:「系统设置 → 备份」立即创建(写入 `$DATA_DIR/backups/`)。
|
|
||||||
- 脚本:`./run.sh backup` 或 `make` 内相应目标,适合 cron 定时。
|
|
||||||
|
|
||||||
## 恢复
|
|
||||||
|
|
||||||
1. 上传统计备份或选择已有备份,校验后暂存。
|
|
||||||
2. 点击应用:平台先把当前库留档(`.before-restore.*`),替换后立即在内存生效,随后建议重启进程。
|
|
||||||
3. 恢复文件损坏时平台拒绝应用并提示。
|
|
||||||
|
|
||||||
## 数据文件保护
|
|
||||||
|
|
||||||
- 启动时若数据文件解析失败,原文件会以 `.corrupt-<时间戳>` 留档后再以空库启动,绝不覆盖可恢复数据。
|
|
||||||
- 历史命名数据文件(opsxloki.json / lokideploy.json)在启动时自动升级为 lokistack.json。
|
|
||||||
|
|
||||||
## 自监控
|
|
||||||
|
|
||||||
`/api/v1/metrics` 输出 Prometheus 指标(HTTP、DB 落盘、告警引擎、Go runtime);「平台监控」页(`/monitor`,管理员)提供可视化快照。
|
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
|
|
||||||
## 平台可以多实例部署吗?
|
## 平台可以多实例部署吗?
|
||||||
|
|
||||||
不可以。JSON 文件存储为单实例设计(防抖串行落盘),水平扩展会互相覆盖;需要高可用时用单实例 + 备份策略。
|
不可以。JSON 文件存储为单实例设计(防抖串行落盘),水平扩展会互相覆盖;需要高可用时请保持单实例并对数据目录做系统级快照。
|
||||||
|
|
||||||
## 升级安全吗?
|
## 升级安全吗?
|
||||||
|
|
||||||
配置与数据向后兼容,结构变更按 schema 版本自动迁移;建议升级前在「系统设置」创建一次备份。
|
配置与数据向后兼容,结构变更按 schema 版本自动迁移;建议升级前对数据目录(lokistack.json)做一次文件级拷贝。
|
||||||
@@ -65,27 +65,6 @@ export interface SelfCheckResult {
|
|||||||
items: SelfCheckItem[];
|
items: SelfCheckItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupFile {
|
|
||||||
name: string;
|
|
||||||
sizeBytes: number;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RestoreResult {
|
|
||||||
manifest: {
|
|
||||||
version: string;
|
|
||||||
createdAt: string;
|
|
||||||
dbBytes: number;
|
|
||||||
recordCount: number;
|
|
||||||
note: string;
|
|
||||||
};
|
|
||||||
keyMatches: boolean;
|
|
||||||
rowsInBackup: number;
|
|
||||||
currentRows: number;
|
|
||||||
stagedPath?: string;
|
|
||||||
warning?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const systemApi = {
|
export const systemApi = {
|
||||||
// 审计
|
// 审计
|
||||||
auditList: (params: AuditListParams = {}, opts?: { signal?: AbortSignal }) =>
|
auditList: (params: AuditListParams = {}, opts?: { signal?: AbortSignal }) =>
|
||||||
@@ -99,16 +78,6 @@ export const systemApi = {
|
|||||||
// 自检
|
// 自检
|
||||||
selfCheck: () => http.get<SelfCheckResult>('/system/self-check'),
|
selfCheck: () => http.get<SelfCheckResult>('/system/self-check'),
|
||||||
|
|
||||||
// 备份恢复
|
|
||||||
backups: () => http.get<{ list: BackupFile[] }>('/system/backups'),
|
|
||||||
restore: (file: File) => {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('file', file);
|
|
||||||
return http.post<RestoreResult>('/system/restore', form);
|
|
||||||
},
|
|
||||||
restoreApply: () =>
|
|
||||||
http.post<{ message: string; dbPath: string }>('/system/restore/apply', { confirm: true }),
|
|
||||||
|
|
||||||
// 平台数据保留期(告警发送历史 / 审计日志)
|
// 平台数据保留期(告警发送历史 / 审计日志)
|
||||||
retention: () => http.get<RetentionSettings>('/settings/retention'),
|
retention: () => http.get<RetentionSettings>('/settings/retention'),
|
||||||
saveRetention: (data: RetentionSettings) => http.put<RetentionSettings>('/settings/retention', data),
|
saveRetention: (data: RetentionSettings) => http.put<RetentionSettings>('/settings/retention', data),
|
||||||
|
|||||||
Reference in new issue
Block a user