mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/1Panel-dev/1Panel.git
synced 2026-09-20 16:13:59 +08:00
708 lines
23 KiB
Go
708 lines
23 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
|
"github.com/1Panel-dev/1Panel/agent/app/task"
|
|
"github.com/1Panel-dev/1Panel/agent/buserr"
|
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
"github.com/1Panel-dev/1Panel/agent/global"
|
|
"github.com/1Panel-dev/1Panel/agent/i18n"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/compose"
|
|
dockerUtils "github.com/1Panel-dev/1Panel/agent/utils/docker"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/files"
|
|
"github.com/docker/docker/api/types/container"
|
|
"github.com/docker/docker/api/types/filters"
|
|
"github.com/docker/docker/client"
|
|
)
|
|
|
|
type composeBackupFile struct {
|
|
OriginalPath string `json:"originalPath"`
|
|
FileName string `json:"fileName"`
|
|
RelativePath string `json:"relativePath,omitempty"`
|
|
BackupPath string `json:"backupPath"`
|
|
}
|
|
|
|
type composeBackupMeta struct {
|
|
ComposeName string `json:"composeName"`
|
|
ComposePath string `json:"composePath"`
|
|
CreatedAt string `json:"createdAt"`
|
|
Files []composeBackupFile `json:"files"`
|
|
Containers []string `json:"containers"`
|
|
}
|
|
|
|
type composeBackupContext struct {
|
|
req dto.CommonBackup
|
|
composeName string
|
|
composePath string
|
|
composeFiles []string
|
|
composeDir string
|
|
fileOp files.FileOp
|
|
dockerClient *client.Client
|
|
stopped bool
|
|
backupDir string
|
|
fileName string
|
|
filePath string
|
|
tmpDir string
|
|
meta composeBackupMeta
|
|
}
|
|
|
|
type composeRecoverContext struct {
|
|
req dto.CommonRecover
|
|
fileOp files.FileOp
|
|
tmpDir string
|
|
meta composeBackupMeta
|
|
composeName string
|
|
targetDir string
|
|
composePath string
|
|
enqueued bool
|
|
}
|
|
|
|
func (u *BackupService) ComposeBackup(req dto.CommonBackup) error {
|
|
timeNow := time.Now().Format(constant.DateTimeSlimLayout) + common.RandStrAndNum(5)
|
|
fileName := req.FileName
|
|
if fileName == "" {
|
|
fileName = fmt.Sprintf("%s_%s.tar.gz", req.Name, timeNow)
|
|
}
|
|
if !strings.HasSuffix(fileName, ".tar.gz") {
|
|
fileName += ".tar.gz"
|
|
}
|
|
itemDir := fmt.Sprintf("compose/%s", req.Name)
|
|
backupDir := path.Join(global.Dir.LocalBackupDir, itemDir)
|
|
record := &model.BackupRecord{
|
|
Type: req.Type,
|
|
Name: req.Name,
|
|
SourceAccountIDs: "1",
|
|
DownloadAccountID: 1,
|
|
FileDir: itemDir,
|
|
FileName: fileName,
|
|
TaskID: req.TaskID,
|
|
Status: constant.StatusWaiting,
|
|
Description: req.Description,
|
|
}
|
|
if err := backupRepo.CreateRecord(record); err != nil {
|
|
global.LOG.Errorf("save compose backup record failed, err: %v", err)
|
|
return err
|
|
}
|
|
if err := handleComposeBackup(req, nil, record.ID, backupDir, fileName); err != nil {
|
|
markBackupFailed(record.ID, err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (u *BackupService) ComposeRecover(req dto.CommonRecover) error {
|
|
return handleComposeRecover(req, nil)
|
|
}
|
|
|
|
func handleComposeBackup(req dto.CommonBackup, parentTask *task.Task, recordID uint, backupDir, fileName string) error {
|
|
composeCtx, err := newComposeBackupContext(req, backupDir, fileName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
containerNames, err := loadComposeContainerNames(composeCtx)
|
|
if err != nil {
|
|
composeCtx.close()
|
|
return err
|
|
}
|
|
|
|
backupTask := parentTask
|
|
if backupTask == nil {
|
|
backupTask, err = task.NewTaskWithOps(composeCtx.composeName, task.TaskBackup, task.TaskScopeBackup, req.TaskID, 1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if req.StopBefore {
|
|
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupStop"), func(t *task.Task) error {
|
|
return stepStopComposeForBackup(composeCtx)
|
|
}, func(t *task.Task) {
|
|
_ = stepStartComposeAfterBackup(composeCtx)
|
|
}, 3, time.Hour)
|
|
}
|
|
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupPrepare"), func(t *task.Task) error { return stepPrepareComposeBackup(composeCtx) }, nil, 3, time.Hour)
|
|
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupFiles"), func(t *task.Task) error { return stepBackupComposeFiles(composeCtx) }, nil, 3, time.Hour)
|
|
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupContainers"), func(t *task.Task) error { return nil }, nil, 3, time.Hour)
|
|
for _, containerName := range containerNames {
|
|
backupFileName := fmt.Sprintf("%s.tar.gz", sanitizeComposeFileName(containerName))
|
|
backupFile := path.Join(composeCtx.tmpDir, "containers", backupFileName)
|
|
if err := handleContainerBackup(containerName, backupTask, 0, path.Dir(backupFile), path.Base(backupFile), "", "", false); err != nil {
|
|
return err
|
|
}
|
|
composeCtx.meta.Containers = append(composeCtx.meta.Containers, path.Join("containers", backupFileName))
|
|
}
|
|
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupMeta"), func(t *task.Task) error { return stepWriteComposeBackupMeta(composeCtx) }, nil, 3, time.Hour)
|
|
backupTask.AddSubTaskWithOps(task.GetTaskName(composeCtx.composeName, task.TaskBackup, task.TaskScopeBackup), func(t *task.Task) error {
|
|
return stepPackComposeBackup(composeCtx)
|
|
}, nil, 3, time.Hour)
|
|
if req.StopBefore {
|
|
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupStart"), func(t *task.Task) error {
|
|
return stepStartComposeAfterBackup(composeCtx)
|
|
}, nil, 3, time.Hour)
|
|
}
|
|
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupCleanup"), func(t *task.Task) error {
|
|
composeCtx.close()
|
|
return nil
|
|
}, nil, 0, time.Hour)
|
|
if parentTask != nil {
|
|
return nil
|
|
}
|
|
go func() {
|
|
defer composeCtx.close()
|
|
if err := backupTask.Execute(); err != nil {
|
|
markBackupFailed(recordID, err)
|
|
return
|
|
}
|
|
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func loadComposeContainerNames(composeCtx *composeBackupContext) ([]string, error) {
|
|
options := container.ListOptions{All: true}
|
|
options.Filters = filters.NewArgs(filters.Arg("label", composeProjectLabel+"="+composeCtx.composeName))
|
|
containers, err := composeCtx.dockerClient.ContainerList(context.Background(), options)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
names := make([]string, 0, len(containers))
|
|
for _, item := range containers {
|
|
if len(item.Names) == 0 {
|
|
continue
|
|
}
|
|
names = append(names, strings.TrimPrefix(item.Names[0], "/"))
|
|
}
|
|
sort.Strings(names)
|
|
return names, nil
|
|
}
|
|
|
|
func handleComposeRecover(req dto.CommonRecover, parentTask *task.Task) error {
|
|
var recoverCtx *composeRecoverContext
|
|
|
|
recoverTask := parentTask
|
|
var err error
|
|
if recoverTask == nil {
|
|
if isImportRecover(req) {
|
|
taskName := i18n.GetMsgByKey("TaskImport") + i18n.GetMsgByKey("Compose")
|
|
recoverTask, err = task.NewTask(taskName, task.TaskImport, task.TaskScopeBackup, req.TaskID, 1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
taskName := req.Name
|
|
if taskName == "" {
|
|
taskName = "compose"
|
|
}
|
|
recoverTask, err = task.NewTaskWithOps(taskName, task.TaskRecover, task.TaskScopeBackup, req.TaskID, 1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
timeout := loadRecoverTimeout(req.Timeout)
|
|
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverPrepare"), func(t *task.Task) error {
|
|
ctx, err := newComposeRecoverContext(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
recoverCtx = ctx
|
|
if err := stepPrepareComposeRecover(recoverCtx); err != nil {
|
|
recoverCtx.close()
|
|
recoverCtx = nil
|
|
return err
|
|
}
|
|
return nil
|
|
}, func(t *task.Task) {
|
|
if recoverCtx != nil {
|
|
recoverCtx.close()
|
|
recoverCtx = nil
|
|
}
|
|
}, 3, timeout)
|
|
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverExtract"), func(t *task.Task) error { return stepExtractComposeRecover(recoverCtx) }, nil, 3, timeout)
|
|
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverMeta"), func(t *task.Task) error {
|
|
if err := stepLoadComposeRecoverMeta(recoverCtx); err != nil {
|
|
return err
|
|
}
|
|
t.Log(i18n.GetMsgWithMap("ComposeRecoverMetaLogName", map[string]interface{}{
|
|
"name": recoverCtx.composeName,
|
|
}))
|
|
t.Log(i18n.GetMsgWithMap("ComposeRecoverMetaLogPath", map[string]interface{}{
|
|
"backupPath": recoverCtx.meta.ComposePath,
|
|
"targetDir": recoverCtx.targetDir,
|
|
}))
|
|
t.Log(i18n.GetMsgWithMap("ComposeRecoverMetaLogCount", map[string]interface{}{
|
|
"files": len(recoverCtx.meta.Files),
|
|
"containers": len(recoverCtx.meta.Containers),
|
|
}))
|
|
return nil
|
|
}, nil, 3, timeout)
|
|
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverFiles"), func(t *task.Task) error { return stepRestoreComposeFiles(recoverCtx) }, nil, 3, timeout)
|
|
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverContainers"), func(t *task.Task) error {
|
|
if recoverCtx.enqueued {
|
|
return nil
|
|
}
|
|
containerItems := make([]string, 0, len(recoverCtx.meta.Containers))
|
|
for _, item := range recoverCtx.meta.Containers {
|
|
backupItem := item
|
|
filePath, err := safeJoinWithinBase(recoverCtx.tmpDir, backupItem)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid container backup path %q, err: %v", backupItem, err)
|
|
}
|
|
if !recoverCtx.fileOp.Stat(filePath) {
|
|
return fmt.Errorf("container backup file not found: %s", backupItem)
|
|
}
|
|
containerItems = append(containerItems, backupItem)
|
|
}
|
|
for _, backupItem := range containerItems {
|
|
filePath, err := safeJoinWithinBase(recoverCtx.tmpDir, backupItem)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid container backup path %q, err: %v", backupItem, err)
|
|
}
|
|
containerLabel := strings.TrimSuffix(path.Base(backupItem), ".tar.gz")
|
|
containerReq := recoverCtx.req
|
|
containerReq.Type = "container"
|
|
containerReq.Name = containerLabel
|
|
containerReq.DetailName = ""
|
|
containerReq.File = filePath
|
|
containerReq.Secret = ""
|
|
if err := handleContainerRecover(containerReq, recoverTask); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverRecord"), func(t *task.Task) error {
|
|
return stepSaveComposeRecord(recoverCtx)
|
|
}, nil, 3, timeout)
|
|
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverCleanup"), func(t *task.Task) error {
|
|
if recoverCtx != nil {
|
|
recoverCtx.close()
|
|
recoverCtx = nil
|
|
}
|
|
return nil
|
|
}, nil, 0, timeout)
|
|
recoverCtx.enqueued = true
|
|
return nil
|
|
}, nil, 3, timeout)
|
|
if parentTask != nil {
|
|
return nil
|
|
}
|
|
go func() {
|
|
_ = recoverTask.Execute()
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func newComposeBackupContext(req dto.CommonBackup, backupDir, fileName string) (*composeBackupContext, error) {
|
|
if req.Name == "" {
|
|
return nil, fmt.Errorf("compose name is required")
|
|
}
|
|
dockerClient, err := dockerUtils.NewDockerClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
composePath, composeFiles, err := loadComposePathAndFiles(req.Name, dockerClient)
|
|
if err != nil {
|
|
_ = dockerClient.Close()
|
|
return nil, err
|
|
}
|
|
filePath := path.Join(backupDir, fileName)
|
|
tmpDir := path.Join(path.Dir(filePath), strings.TrimSuffix(path.Base(filePath), ".tar.gz"))
|
|
ctx := &composeBackupContext{
|
|
req: req,
|
|
composeName: req.Name,
|
|
composePath: composePath,
|
|
composeFiles: composeFiles,
|
|
composeDir: path.Dir(composeFiles[0]),
|
|
fileOp: files.NewFileOp(),
|
|
dockerClient: dockerClient,
|
|
backupDir: backupDir,
|
|
fileName: fileName,
|
|
filePath: filePath,
|
|
tmpDir: tmpDir,
|
|
meta: composeBackupMeta{
|
|
ComposeName: req.Name,
|
|
ComposePath: composePath,
|
|
CreatedAt: time.Now().Format(constant.DateTimeLayout),
|
|
Files: make([]composeBackupFile, 0),
|
|
Containers: make([]string, 0),
|
|
},
|
|
}
|
|
return ctx, nil
|
|
}
|
|
|
|
func loadComposePathAndFiles(composeName string, dockerClient *client.Client) (string, []string, error) {
|
|
composeRecord, _ := composeRepo.GetRecord(repo.WithByName(composeName))
|
|
if composeRecord.ID == 0 {
|
|
composeRecord, _ = composeRepo.GetRecord(repo.WithByName(strings.ToLower(composeName)))
|
|
}
|
|
composePath := composeRecord.Path
|
|
if composePath == "" {
|
|
options := container.ListOptions{All: true}
|
|
options.Filters = filters.NewArgs(filters.Arg("label", composeProjectLabel))
|
|
list, err := dockerClient.ContainerList(context.Background(), options)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
if len(list) == 0 {
|
|
return "", nil, fmt.Errorf("compose %s not found", composeName)
|
|
}
|
|
var targetContainer *container.Summary
|
|
for i := range list {
|
|
if strings.EqualFold(list[i].Labels[composeProjectLabel], composeName) {
|
|
targetContainer = &list[i]
|
|
break
|
|
}
|
|
}
|
|
if targetContainer == nil {
|
|
return "", nil, fmt.Errorf("compose %s not found", composeName)
|
|
}
|
|
config := targetContainer.Labels[composeConfigLabel]
|
|
workdir := targetContainer.Labels[composeWorkdirLabel]
|
|
if len(config) != 0 && len(workdir) != 0 && strings.Contains(config, workdir) {
|
|
composePath = config
|
|
} else {
|
|
composePath = workdir
|
|
}
|
|
}
|
|
composeFiles := normalizeComposeFiles(composePath)
|
|
if len(composeFiles) == 0 {
|
|
return "", nil, fmt.Errorf("compose file not found for %s", composeName)
|
|
}
|
|
return composePath, composeFiles, nil
|
|
}
|
|
|
|
func normalizeComposeFiles(composePath string) []string {
|
|
items := strings.Split(composePath, ",")
|
|
result := make([]string, 0)
|
|
seen := make(map[string]struct{})
|
|
for _, item := range items {
|
|
item = strings.TrimSpace(item)
|
|
if item == "" {
|
|
continue
|
|
}
|
|
stat, err := os.Stat(item)
|
|
if err == nil && stat.IsDir() {
|
|
item = path.Join(item, "docker-compose.yml")
|
|
}
|
|
if _, err := os.Stat(item); err != nil {
|
|
continue
|
|
}
|
|
if _, ok := seen[item]; ok {
|
|
continue
|
|
}
|
|
seen[item] = struct{}{}
|
|
result = append(result, item)
|
|
}
|
|
sort.Strings(result)
|
|
return result
|
|
}
|
|
|
|
func (c *composeBackupContext) close() {
|
|
if c.dockerClient != nil {
|
|
_ = c.dockerClient.Close()
|
|
c.dockerClient = nil
|
|
}
|
|
if c.tmpDir != "" {
|
|
_ = os.RemoveAll(c.tmpDir)
|
|
c.tmpDir = ""
|
|
}
|
|
}
|
|
|
|
func stepPrepareComposeBackup(composeCtx *composeBackupContext) error {
|
|
if err := os.MkdirAll(composeCtx.backupDir, os.ModePerm); err != nil {
|
|
return fmt.Errorf("mkdir %s failed, err: %v", composeCtx.backupDir, err)
|
|
}
|
|
_ = os.RemoveAll(composeCtx.tmpDir)
|
|
if err := os.MkdirAll(path.Join(composeCtx.tmpDir, "compose_files"), os.ModePerm); err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(path.Join(composeCtx.tmpDir, "containers"), os.ModePerm); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func stepStopComposeForBackup(composeCtx *composeBackupContext) error {
|
|
if composeCtx.stopped {
|
|
return nil
|
|
}
|
|
options := container.ListOptions{All: false}
|
|
options.Filters = filters.NewArgs(filters.Arg("label", composeProjectLabel+"="+composeCtx.composeName))
|
|
runningList, err := composeCtx.dockerClient.ContainerList(context.Background(), options)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(runningList) == 0 {
|
|
return nil
|
|
}
|
|
if stdout, err := compose.Operate(composeCtx.composePath, "stop"); err != nil {
|
|
return fmt.Errorf("docker-compose stop failed, std: %s, err: %v", stdout, err)
|
|
}
|
|
composeCtx.stopped = true
|
|
return nil
|
|
}
|
|
|
|
func stepStartComposeAfterBackup(composeCtx *composeBackupContext) error {
|
|
if !composeCtx.stopped {
|
|
return nil
|
|
}
|
|
if stdout, err := compose.Up(composeCtx.composePath); err != nil {
|
|
return fmt.Errorf("docker-compose up failed, std: %s, err: %v", stdout, err)
|
|
}
|
|
composeCtx.stopped = false
|
|
return nil
|
|
}
|
|
|
|
func stepBackupComposeFiles(composeCtx *composeBackupContext) error {
|
|
for i, filePath := range composeCtx.composeFiles {
|
|
backupName := fmt.Sprintf("%02d_%s", i, path.Base(filePath))
|
|
backupPath := path.Join(composeCtx.tmpDir, "compose_files", backupName)
|
|
content, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := composeCtx.fileOp.SaveFile(backupPath, string(content), fs.ModePerm); err != nil {
|
|
return err
|
|
}
|
|
relativePath := path.Base(filePath)
|
|
if composeCtx.composeDir != "" {
|
|
rel, relErr := filepath.Rel(composeCtx.composeDir, filePath)
|
|
if relErr == nil {
|
|
rel = filepath.ToSlash(rel)
|
|
if rel != "" && rel != "." && !strings.HasPrefix(rel, "../") {
|
|
relativePath = rel
|
|
}
|
|
}
|
|
}
|
|
composeCtx.meta.Files = append(composeCtx.meta.Files, composeBackupFile{
|
|
OriginalPath: filePath,
|
|
FileName: path.Base(filePath),
|
|
RelativePath: relativePath,
|
|
BackupPath: path.Join("compose_files", backupName),
|
|
})
|
|
}
|
|
if len(composeCtx.composeFiles) != 0 {
|
|
envPath := path.Join(path.Dir(composeCtx.composeFiles[0]), ".env")
|
|
if composeCtx.fileOp.Stat(envPath) {
|
|
envContent, err := os.ReadFile(envPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := composeCtx.fileOp.SaveFile(path.Join(composeCtx.tmpDir, "compose_files", ".env"), string(envContent), fs.ModePerm); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func stepWriteComposeBackupMeta(composeCtx *composeBackupContext) error {
|
|
metaBytes, err := json.MarshalIndent(composeCtx.meta, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return composeCtx.fileOp.SaveFile(path.Join(composeCtx.tmpDir, "compose_meta.json"), string(metaBytes), fs.ModePerm)
|
|
}
|
|
|
|
func stepPackComposeBackup(composeCtx *composeBackupContext) error {
|
|
return composeCtx.fileOp.TarGzCompressPro(true, composeCtx.tmpDir, composeCtx.filePath, composeCtx.req.Secret, "")
|
|
}
|
|
|
|
func newComposeRecoverContext(req dto.CommonRecover) (*composeRecoverContext, error) {
|
|
tmpDir := path.Join(path.Dir(req.File), strings.TrimSuffix(path.Base(req.File), ".tar.gz"))
|
|
ctx := &composeRecoverContext{
|
|
req: req,
|
|
fileOp: files.NewFileOp(),
|
|
tmpDir: tmpDir,
|
|
meta: composeBackupMeta{
|
|
Files: make([]composeBackupFile, 0),
|
|
Containers: make([]string, 0),
|
|
},
|
|
}
|
|
return ctx, nil
|
|
}
|
|
|
|
func (c *composeRecoverContext) close() {
|
|
if c.tmpDir != "" {
|
|
_ = os.RemoveAll(c.tmpDir)
|
|
}
|
|
}
|
|
|
|
func stepPrepareComposeRecover(recoverCtx *composeRecoverContext) error {
|
|
if !recoverCtx.fileOp.Stat(recoverCtx.req.File) {
|
|
return buserr.WithName("ErrFileNotFound", recoverCtx.req.File)
|
|
}
|
|
_ = os.RemoveAll(recoverCtx.tmpDir)
|
|
return nil
|
|
}
|
|
|
|
func stepExtractComposeRecover(recoverCtx *composeRecoverContext) error {
|
|
return recoverCtx.fileOp.TarGzExtractPro(recoverCtx.req.File, path.Dir(recoverCtx.req.File), recoverCtx.req.Secret)
|
|
}
|
|
|
|
func stepLoadComposeRecoverMeta(recoverCtx *composeRecoverContext) error {
|
|
metaPath := path.Join(recoverCtx.tmpDir, "compose_meta.json")
|
|
if !recoverCtx.fileOp.Stat(metaPath) {
|
|
return fmt.Errorf("compose_meta.json not found in backup file")
|
|
}
|
|
metaBytes, err := os.ReadFile(metaPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(metaBytes, &recoverCtx.meta); err != nil {
|
|
return fmt.Errorf("unmarshal compose_meta.json failed, err: %v", err)
|
|
}
|
|
recoverCtx.composeName = strings.TrimSpace(recoverCtx.req.Name)
|
|
if recoverCtx.composeName == "" {
|
|
recoverCtx.composeName = strings.TrimSpace(recoverCtx.meta.ComposeName)
|
|
}
|
|
if recoverCtx.composeName == "" {
|
|
return fmt.Errorf("compose name not found in recover request or backup file")
|
|
}
|
|
recoverCtx.targetDir = resolveComposeRecoverTargetDir(recoverCtx.meta, recoverCtx.composeName)
|
|
return nil
|
|
}
|
|
|
|
func resolveComposeRecoverTargetDir(meta composeBackupMeta, composeName string) string {
|
|
composePath := strings.TrimSpace(meta.ComposePath)
|
|
if composePath != "" {
|
|
items := strings.Split(composePath, ",")
|
|
for _, item := range items {
|
|
p := strings.TrimSpace(item)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
ext := strings.ToLower(path.Ext(p))
|
|
if ext == ".yml" || ext == ".yaml" {
|
|
return path.Dir(p)
|
|
}
|
|
return p
|
|
}
|
|
}
|
|
return path.Join(global.Dir.DataDir, "docker/compose", composeName)
|
|
}
|
|
|
|
func safeJoinWithinBase(baseDir, name string) (string, error) {
|
|
base := filepath.Clean(baseDir)
|
|
candidate := strings.TrimSpace(name)
|
|
candidate = strings.ReplaceAll(candidate, "\\", "/")
|
|
candidate = filepath.Clean(filepath.FromSlash(candidate))
|
|
if candidate == "" || candidate == "." {
|
|
return "", fmt.Errorf("invalid path: empty")
|
|
}
|
|
if filepath.IsAbs(candidate) {
|
|
return "", fmt.Errorf("invalid path %q: absolute path is not allowed", name)
|
|
}
|
|
if candidate == ".." || strings.HasPrefix(candidate, ".."+string(filepath.Separator)) {
|
|
return "", fmt.Errorf("invalid path %q: path escapes base directory", name)
|
|
}
|
|
resolved := filepath.Clean(filepath.Join(base, candidate))
|
|
rel, err := filepath.Rel(base, resolved)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve path %q failed, err: %v", name, err)
|
|
}
|
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return "", fmt.Errorf("invalid path %q: path escapes base directory", name)
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error {
|
|
if recoverCtx.targetDir != "" {
|
|
_ = os.RemoveAll(recoverCtx.targetDir)
|
|
}
|
|
if err := os.MkdirAll(recoverCtx.targetDir, os.ModePerm); err != nil {
|
|
return err
|
|
}
|
|
restored := make([]string, 0, len(recoverCtx.meta.Files))
|
|
for _, item := range recoverCtx.meta.Files {
|
|
backupPath, err := safeJoinWithinBase(recoverCtx.tmpDir, item.BackupPath)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid compose backup path %q, err: %v", item.BackupPath, err)
|
|
}
|
|
if !recoverCtx.fileOp.Stat(backupPath) {
|
|
continue
|
|
}
|
|
targetName := item.FileName
|
|
if item.RelativePath != "" {
|
|
targetName = item.RelativePath
|
|
}
|
|
if targetName == "" {
|
|
targetName = path.Base(item.OriginalPath)
|
|
}
|
|
if targetName == "" {
|
|
targetName = "docker-compose.yml"
|
|
}
|
|
targetPath, err := safeJoinWithinBase(recoverCtx.targetDir, targetName)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid compose target path %q, err: %v", targetName, err)
|
|
}
|
|
if err := os.MkdirAll(path.Dir(targetPath), os.ModePerm); err != nil {
|
|
return err
|
|
}
|
|
content, err := os.ReadFile(backupPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := recoverCtx.fileOp.SaveFile(targetPath, string(content), fs.ModePerm); err != nil {
|
|
return err
|
|
}
|
|
restored = append(restored, targetPath)
|
|
}
|
|
envPath := path.Join(recoverCtx.tmpDir, "compose_files", ".env")
|
|
if recoverCtx.fileOp.Stat(envPath) {
|
|
envContent, err := os.ReadFile(envPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := recoverCtx.fileOp.SaveFile(path.Join(recoverCtx.targetDir, ".env"), string(envContent), fs.ModePerm); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(restored) == 0 {
|
|
defaultPath := path.Join(recoverCtx.targetDir, "docker-compose.yml")
|
|
if !recoverCtx.fileOp.Stat(defaultPath) {
|
|
return fmt.Errorf("compose file not found in backup data")
|
|
}
|
|
restored = append(restored, defaultPath)
|
|
}
|
|
sort.Strings(restored)
|
|
recoverCtx.composePath = strings.Join(restored, ",")
|
|
return nil
|
|
}
|
|
|
|
func stepSaveComposeRecord(recoverCtx *composeRecoverContext) error {
|
|
if recoverCtx.composePath == "" {
|
|
recoverCtx.composePath = path.Join(recoverCtx.targetDir, "docker-compose.yml")
|
|
}
|
|
recordName := strings.ToLower(recoverCtx.composeName)
|
|
record, _ := composeRepo.GetRecord(repo.WithByName(recordName))
|
|
if record.ID == 0 {
|
|
return composeRepo.CreateRecord(&model.Compose{Name: recordName, Path: recoverCtx.composePath})
|
|
}
|
|
return composeRepo.UpdateRecord(recordName, map[string]interface{}{"path": recoverCtx.composePath})
|
|
}
|
|
|
|
func sanitizeComposeFileName(in string) string {
|
|
name := strings.TrimSpace(in)
|
|
name = strings.ReplaceAll(name, "/", "_")
|
|
name = strings.ReplaceAll(name, ":", "_")
|
|
if name == "" {
|
|
return "container"
|
|
}
|
|
return name
|
|
}
|