mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-09-22 08:00:53 +00:00
feat: complete backup and recovery for containers and compose (#12153)
Refs #9656
This commit is contained in:
@@ -430,6 +430,16 @@ func (b *BaseApi) Backup(c *gin.Context) {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
case "container":
|
||||
if err := backupService.ContainerBackup(req); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
case "compose":
|
||||
if err := backupService.ComposeBackup(req); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
@@ -485,6 +495,16 @@ func (b *BaseApi) Recover(c *gin.Context) {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
case "container":
|
||||
if err := backupService.ContainerRecover(req); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
case "compose":
|
||||
if err := backupService.ComposeRecover(req); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
@@ -525,6 +545,16 @@ func (b *BaseApi) RecoverByUpload(c *gin.Context) {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
case "container":
|
||||
if err := backupService.ContainerRecover(req); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
case "compose":
|
||||
if err := backupService.ComposeRecover(req); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
@@ -65,10 +65,11 @@ type UploadForRecover struct {
|
||||
}
|
||||
|
||||
type CommonBackup struct {
|
||||
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mysql-cluster postgresql-cluster redis-cluster"`
|
||||
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mysql-cluster postgresql-cluster redis-cluster container compose"`
|
||||
Name string `json:"name"`
|
||||
DetailName string `json:"detailName"`
|
||||
Secret string `json:"secret"`
|
||||
StopBefore bool `json:"stopBefore"`
|
||||
TaskID string `json:"taskID"`
|
||||
FileName string `json:"fileName"`
|
||||
Args []string `json:"args"`
|
||||
@@ -77,7 +78,7 @@ type CommonBackup struct {
|
||||
}
|
||||
type CommonRecover struct {
|
||||
DownloadAccountID uint `json:"downloadAccountID" validate:"required"`
|
||||
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mysql-cluster postgresql-cluster redis-cluster"`
|
||||
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mysql-cluster postgresql-cluster redis-cluster container compose"`
|
||||
Name string `json:"name"`
|
||||
DetailName string `json:"detailName"`
|
||||
File string `json:"file"`
|
||||
|
||||
@@ -57,6 +57,11 @@ type IBackupService interface {
|
||||
|
||||
AppBackup(db dto.CommonBackup) (*model.BackupRecord, error)
|
||||
AppRecover(req dto.CommonRecover) error
|
||||
|
||||
ContainerBackup(req dto.CommonBackup) error
|
||||
ContainerRecover(req dto.CommonRecover) error
|
||||
ComposeBackup(req dto.CommonBackup) error
|
||||
ComposeRecover(req dto.CommonRecover) error
|
||||
}
|
||||
|
||||
func NewIBackupService() IBackupService {
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
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 {
|
||||
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
|
||||
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 {
|
||||
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,891 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"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/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"
|
||||
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/mount"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
type containerBackupMeta struct {
|
||||
ContainerName string `json:"containerName"`
|
||||
ContainerID string `json:"containerID"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Image string `json:"image"`
|
||||
HostConfig *container.HostConfig `json:"hostConfig,omitempty"`
|
||||
Config *container.Config `json:"config,omitempty"`
|
||||
Mounts []containerMountBackup `json:"mounts"`
|
||||
}
|
||||
|
||||
type containerMountBackup struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Destination string `json:"destination"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
RW bool `json:"rw"`
|
||||
Propagation string `json:"propagation,omitempty"`
|
||||
BackupPath string `json:"backupPath,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type containerBackupContext struct {
|
||||
containerName string
|
||||
backupDir string
|
||||
fileName string
|
||||
secret string
|
||||
filePath string
|
||||
tmpDir string
|
||||
mountRoot string
|
||||
wasRunning bool
|
||||
stopped bool
|
||||
fileOp files.FileOp
|
||||
inspectInfo container.InspectResponse
|
||||
meta containerBackupMeta
|
||||
}
|
||||
|
||||
type containerRecoverContext struct {
|
||||
req dto.CommonRecover
|
||||
targetName string
|
||||
fileOp files.FileOp
|
||||
client *client.Client
|
||||
tmpDir string
|
||||
meta containerBackupMeta
|
||||
inspectInfo container.InspectResponse
|
||||
shouldStart bool
|
||||
createdContainerID string
|
||||
}
|
||||
|
||||
func (u *BackupService) ContainerBackup(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("container/%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 backup record failed, err: %v", err)
|
||||
return err
|
||||
}
|
||||
if err := handleContainerBackup(req.Name, nil, record.ID, backupDir, fileName, req.TaskID, req.Secret, req.StopBefore); err != nil {
|
||||
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *BackupService) ContainerRecover(req dto.CommonRecover) error {
|
||||
return handleContainerRecover(req, nil)
|
||||
}
|
||||
|
||||
func handleContainerBackup(containerName string, parentTask *task.Task, recordID uint, backupDir, fileName, taskID, secret string, stopBefore bool) error {
|
||||
var (
|
||||
err error
|
||||
backupTask *task.Task
|
||||
)
|
||||
backupCtx, err := newContainerBackupContext(containerName, backupDir, fileName, secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backupTask = parentTask
|
||||
if backupTask == nil {
|
||||
backupTask, err = task.NewTaskWithOps(containerName, task.TaskBackup, task.TaskScopeBackup, taskID, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if stopBefore {
|
||||
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupStop"), func(t *task.Task) error {
|
||||
return stepStopContainerForBackup(backupCtx)
|
||||
}, func(t *task.Task) {
|
||||
_ = stepStartContainerAfterBackup(backupCtx)
|
||||
}, 3, time.Hour)
|
||||
}
|
||||
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupPrepare"), func(t *task.Task) error {
|
||||
t.Logf("------------------ %s ------------------", containerName)
|
||||
return stepPrepareContainerBackup(backupCtx)
|
||||
}, nil, 3, time.Hour)
|
||||
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupInspect"), func(t *task.Task) error { return stepBackupContainerInspect(backupCtx) }, nil, 3, time.Hour)
|
||||
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupMounts"), func(t *task.Task) error { return stepBackupContainerMounts(backupCtx) }, nil, 3, time.Hour)
|
||||
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupMeta"), func(t *task.Task) error { return stepWriteContainerMeta(backupCtx) }, nil, 3, time.Hour)
|
||||
backupTask.AddSubTaskWithOps(task.GetTaskName(containerName, task.TaskBackup, task.TaskScopeBackup), func(t *task.Task) error { return stepPackContainerBackup(backupCtx) }, nil, 3, time.Hour)
|
||||
if stopBefore {
|
||||
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupStart"), func(t *task.Task) error {
|
||||
return stepStartContainerAfterBackup(backupCtx)
|
||||
}, nil, 3, time.Hour)
|
||||
}
|
||||
if parentTask != nil {
|
||||
return nil
|
||||
}
|
||||
go func() {
|
||||
defer backupCtx.close()
|
||||
if err := backupTask.Execute(); err != nil {
|
||||
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleContainerRecover(req dto.CommonRecover, parentTask *task.Task) error {
|
||||
var (
|
||||
err error
|
||||
recoverTask *task.Task
|
||||
recoverCtx *containerRecoverContext
|
||||
)
|
||||
recoverTask = parentTask
|
||||
if recoverTask == nil {
|
||||
if isImportRecover(req) {
|
||||
taskName := i18n.GetMsgByKey("TaskImport") + i18n.GetMsgByKey("Container")
|
||||
recoverTask, err = task.NewTask(taskName, task.TaskImport, task.TaskScopeBackup, req.TaskID, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
recoverTask, err = task.NewTaskWithOps("container", task.TaskRecover, task.TaskScopeBackup, req.TaskID, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timeout := loadRecoverTimeout(req.Timeout)
|
||||
logName := strings.TrimSpace(req.Name)
|
||||
if logName == "" && req.File != "" {
|
||||
logName = strings.TrimSuffix(path.Base(req.File), ".tar.gz")
|
||||
}
|
||||
if logName == "" {
|
||||
logName = "container"
|
||||
}
|
||||
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverPrepare"), func(t *task.Task) error {
|
||||
ctx, err := newContainerRecoverContext(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recoverCtx = ctx
|
||||
t.Logf("------------------ %s ------------------", logName)
|
||||
if err := stepPrepareContainerRecover(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("ContainerRecoverExtract"), func(t *task.Task) error { return stepExtractContainerRecover(recoverCtx) }, nil, 3, timeout)
|
||||
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverParse"), func(t *task.Task) error { return stepLoadContainerRecoverData(recoverCtx) }, nil, 3, timeout)
|
||||
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverCreate"), func(t *task.Task) error { return stepRecreateContainer(recoverCtx, t) }, nil, 3, timeout)
|
||||
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverMounts"), func(t *task.Task) error { return stepRestoreContainerMounts(recoverCtx) }, nil, 3, timeout)
|
||||
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverStart"), func(t *task.Task) error { return stepStartRecoveredContainer(recoverCtx) }, nil, 3, timeout)
|
||||
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverCleanup"), func(t *task.Task) error {
|
||||
if recoverCtx != nil {
|
||||
recoverCtx.close()
|
||||
recoverCtx = nil
|
||||
}
|
||||
return nil
|
||||
}, nil, 0, timeout)
|
||||
if parentTask != nil {
|
||||
return nil
|
||||
}
|
||||
go func() {
|
||||
_ = recoverTask.Execute()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadRecoverTimeout(timeout int) time.Duration {
|
||||
switch timeout {
|
||||
case -1:
|
||||
return 0
|
||||
case 0:
|
||||
return 3 * time.Hour
|
||||
default:
|
||||
return time.Duration(timeout) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
func isImportRecover(req dto.CommonRecover) bool {
|
||||
return req.BackupRecordID == 0
|
||||
}
|
||||
|
||||
func newContainerBackupContext(containerName, backupDir, fileName, secret string) (*containerBackupContext, error) {
|
||||
dockerClient, err := dockerUtils.NewDockerClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = dockerClient.Close()
|
||||
}()
|
||||
inspectInfo, err := dockerClient.ContainerInspect(context.Background(), containerName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filePath := path.Join(backupDir, fileName)
|
||||
tmpDir := path.Join(path.Dir(filePath), strings.TrimSuffix(path.Base(filePath), ".tar.gz"))
|
||||
backupCtx := &containerBackupContext{
|
||||
containerName: containerName,
|
||||
backupDir: backupDir,
|
||||
fileName: fileName,
|
||||
secret: secret,
|
||||
filePath: filePath,
|
||||
tmpDir: tmpDir,
|
||||
mountRoot: path.Join(tmpDir, "mounts"),
|
||||
wasRunning: inspectInfo.State != nil && inspectInfo.State.Running,
|
||||
fileOp: files.NewFileOp(),
|
||||
inspectInfo: inspectInfo,
|
||||
meta: containerBackupMeta{
|
||||
ContainerName: containerName,
|
||||
ContainerID: inspectInfo.ID,
|
||||
CreatedAt: time.Now().Format(constant.DateTimeLayout),
|
||||
Image: inspectInfo.Config.Image,
|
||||
HostConfig: inspectInfo.HostConfig,
|
||||
Config: inspectInfo.Config,
|
||||
Mounts: make([]containerMountBackup, 0),
|
||||
},
|
||||
}
|
||||
return backupCtx, nil
|
||||
}
|
||||
|
||||
func newContainerRecoverContext(req dto.CommonRecover) (*containerRecoverContext, error) {
|
||||
dockerClient, err := dockerUtils.NewDockerClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmpDir := path.Join(path.Dir(req.File), strings.TrimSuffix(path.Base(req.File), ".tar.gz"))
|
||||
ctx := &containerRecoverContext{
|
||||
req: req,
|
||||
targetName: req.Name,
|
||||
fileOp: files.NewFileOp(),
|
||||
client: dockerClient,
|
||||
tmpDir: tmpDir,
|
||||
meta: containerBackupMeta{
|
||||
Mounts: make([]containerMountBackup, 0),
|
||||
},
|
||||
}
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func (c *containerBackupContext) close() {
|
||||
if c.tmpDir != "" {
|
||||
_ = os.RemoveAll(c.tmpDir)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *containerRecoverContext) close() {
|
||||
if c.client != nil {
|
||||
_ = c.client.Close()
|
||||
}
|
||||
if c.tmpDir != "" {
|
||||
_ = os.RemoveAll(c.tmpDir)
|
||||
}
|
||||
}
|
||||
|
||||
func stepPrepareContainerBackup(backupCtx *containerBackupContext) error {
|
||||
if err := os.MkdirAll(backupCtx.backupDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("mkdir %s failed, err: %v", backupCtx.backupDir, err)
|
||||
}
|
||||
_ = os.RemoveAll(backupCtx.tmpDir)
|
||||
if err := os.MkdirAll(backupCtx.mountRoot, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepStopContainerForBackup(backupCtx *containerBackupContext) error {
|
||||
if !backupCtx.wasRunning || backupCtx.stopped {
|
||||
return nil
|
||||
}
|
||||
dockerClient, err := dockerUtils.NewDockerClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = dockerClient.Close()
|
||||
}()
|
||||
if err := dockerClient.ContainerStop(context.Background(), backupCtx.inspectInfo.ID, container.StopOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
backupCtx.stopped = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepStartContainerAfterBackup(backupCtx *containerBackupContext) error {
|
||||
if !backupCtx.stopped {
|
||||
return nil
|
||||
}
|
||||
dockerClient, err := dockerUtils.NewDockerClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = dockerClient.Close()
|
||||
}()
|
||||
if err := dockerClient.ContainerStart(context.Background(), backupCtx.inspectInfo.ID, container.StartOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
backupCtx.stopped = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepBackupContainerInspect(backupCtx *containerBackupContext) error {
|
||||
inspectBytes, err := json.MarshalIndent(backupCtx.inspectInfo, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backupCtx.fileOp.SaveFile(path.Join(backupCtx.tmpDir, "inspect.json"), string(inspectBytes), fs.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
if backupCtx.inspectInfo.NetworkSettings != nil {
|
||||
networkBytes, err := json.MarshalIndent(backupCtx.inspectInfo.NetworkSettings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backupCtx.fileOp.SaveFile(path.Join(backupCtx.tmpDir, "network.json"), string(networkBytes), fs.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepBackupContainerMounts(backupCtx *containerBackupContext) error {
|
||||
var (
|
||||
dockerClient *client.Client
|
||||
clientErr error
|
||||
)
|
||||
ensureClient := func() (*client.Client, error) {
|
||||
if dockerClient != nil || clientErr != nil {
|
||||
return dockerClient, clientErr
|
||||
}
|
||||
dockerClient, clientErr = dockerUtils.NewDockerClient()
|
||||
return dockerClient, clientErr
|
||||
}
|
||||
defer func() {
|
||||
if dockerClient != nil {
|
||||
_ = dockerClient.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
for i, item := range backupCtx.inspectInfo.Mounts {
|
||||
mountMeta := containerMountBackup{
|
||||
Type: string(item.Type),
|
||||
Name: item.Name,
|
||||
Source: item.Source,
|
||||
Destination: item.Destination,
|
||||
Mode: item.Mode,
|
||||
RW: item.RW,
|
||||
Propagation: string(item.Propagation),
|
||||
Status: "skipped",
|
||||
}
|
||||
|
||||
mountDirName := fmt.Sprintf("%02d_%s", i, sanitizeContainerMountName(item.Destination))
|
||||
mountDir := path.Join(backupCtx.mountRoot, mountDirName)
|
||||
mountMeta.BackupPath = path.Join("mounts", mountDirName, "data")
|
||||
|
||||
switch item.Type {
|
||||
case mount.TypeBind, mount.TypeVolume:
|
||||
if item.Source == "" {
|
||||
mountMeta.Message = "empty source"
|
||||
backupCtx.meta.Mounts = append(backupCtx.meta.Mounts, mountMeta)
|
||||
continue
|
||||
}
|
||||
sourceInfo, statErr := os.Stat(item.Source)
|
||||
if statErr != nil {
|
||||
mountMeta.Message = statErr.Error()
|
||||
backupCtx.meta.Mounts = append(backupCtx.meta.Mounts, mountMeta)
|
||||
continue
|
||||
}
|
||||
dataDir := path.Join(mountDir, "data")
|
||||
if err := os.MkdirAll(dataDir, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
if sourceInfo.IsDir() {
|
||||
if err := backupCtx.fileOp.CopyDirWithNewName(item.Source, dataDir, "."); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := backupCtx.fileOp.CopyFile(item.Source, dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if item.Type == mount.TypeVolume && item.Name != "" {
|
||||
cli, err := ensureClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
volumeInfo, volumeErr := cli.VolumeInspect(context.Background(), item.Name)
|
||||
if volumeErr == nil {
|
||||
volumeBytes, volumeMarshalErr := json.MarshalIndent(volumeInfo, "", " ")
|
||||
if volumeMarshalErr == nil {
|
||||
_ = backupCtx.fileOp.SaveFile(path.Join(mountDir, "volume.json"), string(volumeBytes), fs.ModePerm)
|
||||
}
|
||||
}
|
||||
}
|
||||
mountMeta.Status = "backed_up"
|
||||
default:
|
||||
mountMeta.Message = "mount type not supported for data backup"
|
||||
}
|
||||
backupCtx.meta.Mounts = append(backupCtx.meta.Mounts, mountMeta)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepWriteContainerMeta(backupCtx *containerBackupContext) error {
|
||||
metaBytes, err := json.MarshalIndent(backupCtx.meta, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backupCtx.fileOp.SaveFile(path.Join(backupCtx.tmpDir, "meta.json"), string(metaBytes), fs.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepPackContainerBackup(backupCtx *containerBackupContext) error {
|
||||
if err := backupCtx.fileOp.TarGzCompressPro(true, backupCtx.tmpDir, backupCtx.filePath, backupCtx.secret, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepPrepareContainerRecover(recoverCtx *containerRecoverContext) error {
|
||||
if !recoverCtx.fileOp.Stat(recoverCtx.req.File) {
|
||||
return buserr.WithName("ErrFileNotFound", recoverCtx.req.File)
|
||||
}
|
||||
_ = os.RemoveAll(recoverCtx.tmpDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepExtractContainerRecover(recoverCtx *containerRecoverContext) error {
|
||||
return recoverCtx.fileOp.TarGzExtractPro(recoverCtx.req.File, path.Dir(recoverCtx.req.File), recoverCtx.req.Secret)
|
||||
}
|
||||
|
||||
func stepLoadContainerRecoverData(recoverCtx *containerRecoverContext) error {
|
||||
if err := loadContainerRecoverMeta(recoverCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := loadContainerRecoverInspect(recoverCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
if recoverCtx.targetName == "" {
|
||||
recoverCtx.targetName = strings.TrimPrefix(recoverCtx.inspectInfo.Name, "/")
|
||||
}
|
||||
if recoverCtx.targetName == "" {
|
||||
recoverCtx.targetName = recoverCtx.meta.ContainerName
|
||||
}
|
||||
if recoverCtx.targetName == "" {
|
||||
return fmt.Errorf("container name not found in recover request or backup file")
|
||||
}
|
||||
if recoverCtx.inspectInfo.Config == nil {
|
||||
recoverCtx.inspectInfo.Config = recoverCtx.meta.Config
|
||||
}
|
||||
if recoverCtx.inspectInfo.HostConfig == nil {
|
||||
recoverCtx.inspectInfo.HostConfig = recoverCtx.meta.HostConfig
|
||||
}
|
||||
if recoverCtx.inspectInfo.Config == nil {
|
||||
return fmt.Errorf("container config not found in backup file")
|
||||
}
|
||||
if recoverCtx.inspectInfo.HostConfig == nil {
|
||||
recoverCtx.inspectInfo.HostConfig = &container.HostConfig{}
|
||||
}
|
||||
recoverCtx.shouldStart = recoverCtx.inspectInfo.State != nil && recoverCtx.inspectInfo.State.Running
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadContainerRecoverMeta(recoverCtx *containerRecoverContext) error {
|
||||
metaPath := path.Join(recoverCtx.tmpDir, "meta.json")
|
||||
if !recoverCtx.fileOp.Stat(metaPath) {
|
||||
return nil
|
||||
}
|
||||
metaBytes, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(metaBytes, &recoverCtx.meta); err != nil {
|
||||
return fmt.Errorf("unmarshal meta.json failed, err: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadContainerRecoverInspect(recoverCtx *containerRecoverContext) error {
|
||||
inspectPath := path.Join(recoverCtx.tmpDir, "inspect.json")
|
||||
if !recoverCtx.fileOp.Stat(inspectPath) {
|
||||
return fmt.Errorf("inspect.json not found in backup file")
|
||||
}
|
||||
inspectBytes, err := os.ReadFile(inspectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(inspectBytes, &recoverCtx.inspectInfo); err != nil {
|
||||
return fmt.Errorf("unmarshal inspect.json failed, err: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepRecreateContainer(recoverCtx *containerRecoverContext, taskItem *task.Task) error {
|
||||
ctx := context.Background()
|
||||
if err := ensureContainerRecoverNetworks(recoverCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureContainerRecoverVolumes(recoverCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config := cloneContainerConfig(recoverCtx.inspectInfo.Config)
|
||||
hostConfig := cloneContainerHostConfig(recoverCtx.inspectInfo.HostConfig)
|
||||
if config.Image == "" {
|
||||
config.Image = recoverCtx.meta.Image
|
||||
}
|
||||
if config.Image == "" {
|
||||
return fmt.Errorf("container image not found in backup file")
|
||||
}
|
||||
if !checkImageExist(recoverCtx.client, config.Image) {
|
||||
if err := pullImages(taskItem, recoverCtx.client, config.Image); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := recoverCtx.client.ContainerInspect(ctx, recoverCtx.targetName); err == nil {
|
||||
if err := recoverCtx.client.ContainerRemove(ctx, recoverCtx.targetName, container.RemoveOptions{Force: true, RemoveVolumes: false}); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !client.IsErrNotFound(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
networkConfig, extraNetworks := buildContainerRecoverNetworkConfig(recoverCtx.inspectInfo.NetworkSettings, hostConfig)
|
||||
createRes, err := recoverCtx.client.ContainerCreate(ctx, config, hostConfig, networkConfig, nil, recoverCtx.targetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recoverCtx.createdContainerID = createRes.ID
|
||||
|
||||
extraNames := make([]string, 0, len(extraNetworks))
|
||||
for name := range extraNetworks {
|
||||
extraNames = append(extraNames, name)
|
||||
}
|
||||
sort.Strings(extraNames)
|
||||
for _, item := range extraNames {
|
||||
if err := recoverCtx.client.NetworkConnect(ctx, item, recoverCtx.createdContainerID, extraNetworks[item]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureContainerRecoverNetworks(recoverCtx *containerRecoverContext) error {
|
||||
if recoverCtx.inspectInfo.NetworkSettings == nil {
|
||||
return nil
|
||||
}
|
||||
for netName := range recoverCtx.inspectInfo.NetworkSettings.Networks {
|
||||
if netName == "" || netName == "bridge" || netName == "host" || netName == "none" {
|
||||
continue
|
||||
}
|
||||
if _, err := recoverCtx.client.NetworkInspect(context.Background(), netName, network.InspectOptions{}); err != nil {
|
||||
if !client.IsErrNotFound(err) {
|
||||
return err
|
||||
}
|
||||
if _, err := recoverCtx.client.NetworkCreate(context.Background(), netName, network.CreateOptions{Driver: "bridge"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureContainerRecoverVolumes(recoverCtx *containerRecoverContext) error {
|
||||
for _, item := range recoverCtx.meta.Mounts {
|
||||
if item.Type != string(mount.TypeVolume) || item.Name == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := recoverCtx.client.VolumeInspect(context.Background(), item.Name); err == nil {
|
||||
continue
|
||||
} else if !client.IsErrNotFound(err) {
|
||||
return err
|
||||
}
|
||||
createOptions := volume.CreateOptions{Name: item.Name}
|
||||
if item.BackupPath != "" {
|
||||
volumeMetaPath := path.Join(recoverCtx.tmpDir, path.Dir(item.BackupPath), "volume.json")
|
||||
if recoverCtx.fileOp.Stat(volumeMetaPath) {
|
||||
volumeBytes, readErr := os.ReadFile(volumeMetaPath)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
var volumeInfo volume.Volume
|
||||
if unmarshalErr := json.Unmarshal(volumeBytes, &volumeInfo); unmarshalErr != nil {
|
||||
return unmarshalErr
|
||||
}
|
||||
if volumeInfo.Driver != "" {
|
||||
createOptions.Driver = volumeInfo.Driver
|
||||
}
|
||||
if len(volumeInfo.Options) != 0 {
|
||||
createOptions.DriverOpts = volumeInfo.Options
|
||||
}
|
||||
if len(volumeInfo.Labels) != 0 {
|
||||
createOptions.Labels = volumeInfo.Labels
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := recoverCtx.client.VolumeCreate(context.Background(), createOptions); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildContainerRecoverNetworkConfig(networkSettings *container.NetworkSettings, hostConfig *container.HostConfig) (*network.NetworkingConfig, map[string]*network.EndpointSettings) {
|
||||
extraNetworks := make(map[string]*network.EndpointSettings)
|
||||
if hostConfig != nil {
|
||||
networkMode := string(hostConfig.NetworkMode)
|
||||
if networkMode == "host" || networkMode == "none" {
|
||||
return nil, extraNetworks
|
||||
}
|
||||
}
|
||||
if networkSettings == nil || len(networkSettings.Networks) == 0 {
|
||||
return nil, extraNetworks
|
||||
}
|
||||
|
||||
primaryName := ""
|
||||
if hostConfig != nil {
|
||||
networkMode := string(hostConfig.NetworkMode)
|
||||
if networkMode != "" && networkMode != "default" && networkMode != "bridge" {
|
||||
if _, ok := networkSettings.Networks[networkMode]; ok {
|
||||
primaryName = networkMode
|
||||
}
|
||||
}
|
||||
}
|
||||
if primaryName == "" {
|
||||
if _, ok := networkSettings.Networks["bridge"]; ok {
|
||||
primaryName = "bridge"
|
||||
} else {
|
||||
names := make([]string, 0, len(networkSettings.Networks))
|
||||
for name := range networkSettings.Networks {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
if len(names) > 0 {
|
||||
primaryName = names[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config := &network.NetworkingConfig{EndpointsConfig: make(map[string]*network.EndpointSettings)}
|
||||
for name, endpoint := range networkSettings.Networks {
|
||||
if name == "host" || name == "none" {
|
||||
continue
|
||||
}
|
||||
endpointSetting := &network.EndpointSettings{Aliases: append([]string(nil), endpoint.Aliases...), MacAddress: endpoint.MacAddress}
|
||||
if endpoint.IPAMConfig != nil {
|
||||
endpointSetting.IPAMConfig = &network.EndpointIPAMConfig{
|
||||
IPv4Address: endpoint.IPAMConfig.IPv4Address,
|
||||
IPv6Address: endpoint.IPAMConfig.IPv6Address,
|
||||
}
|
||||
} else if endpoint.IPAddress != "" || endpoint.GlobalIPv6Address != "" {
|
||||
endpointSetting.IPAMConfig = &network.EndpointIPAMConfig{
|
||||
IPv4Address: endpoint.IPAddress,
|
||||
IPv6Address: endpoint.GlobalIPv6Address,
|
||||
}
|
||||
}
|
||||
if name == primaryName {
|
||||
config.EndpointsConfig[name] = endpointSetting
|
||||
} else {
|
||||
extraNetworks[name] = endpointSetting
|
||||
}
|
||||
}
|
||||
if len(config.EndpointsConfig) == 0 {
|
||||
return nil, extraNetworks
|
||||
}
|
||||
return config, extraNetworks
|
||||
}
|
||||
|
||||
func cloneContainerConfig(config *container.Config) *container.Config {
|
||||
if config == nil {
|
||||
return &container.Config{}
|
||||
}
|
||||
item := *config
|
||||
if len(config.Env) != 0 {
|
||||
item.Env = append([]string(nil), config.Env...)
|
||||
}
|
||||
if len(config.Cmd) != 0 {
|
||||
item.Cmd = append([]string(nil), config.Cmd...)
|
||||
}
|
||||
if len(config.Entrypoint) != 0 {
|
||||
item.Entrypoint = append([]string(nil), config.Entrypoint...)
|
||||
}
|
||||
if len(config.Labels) != 0 {
|
||||
labels := make(map[string]string, len(config.Labels))
|
||||
for key, val := range config.Labels {
|
||||
labels[key] = val
|
||||
}
|
||||
item.Labels = labels
|
||||
}
|
||||
if len(config.Volumes) != 0 {
|
||||
volumes := make(map[string]struct{}, len(config.Volumes))
|
||||
for key, val := range config.Volumes {
|
||||
volumes[key] = val
|
||||
}
|
||||
item.Volumes = volumes
|
||||
}
|
||||
return &item
|
||||
}
|
||||
|
||||
func cloneContainerHostConfig(hostConfig *container.HostConfig) *container.HostConfig {
|
||||
if hostConfig == nil {
|
||||
return &container.HostConfig{}
|
||||
}
|
||||
item := *hostConfig
|
||||
if len(hostConfig.Binds) != 0 {
|
||||
item.Binds = append([]string(nil), hostConfig.Binds...)
|
||||
}
|
||||
if len(hostConfig.DNS) != 0 {
|
||||
item.DNS = append([]string(nil), hostConfig.DNS...)
|
||||
}
|
||||
if len(hostConfig.ExtraHosts) != 0 {
|
||||
item.ExtraHosts = append([]string(nil), hostConfig.ExtraHosts...)
|
||||
}
|
||||
if len(hostConfig.Mounts) != 0 {
|
||||
item.Mounts = append([]mount.Mount(nil), hostConfig.Mounts...)
|
||||
}
|
||||
return &item
|
||||
}
|
||||
|
||||
func stepRestoreContainerMounts(recoverCtx *containerRecoverContext) error {
|
||||
currentContainer := recoverCtx.createdContainerID
|
||||
if currentContainer == "" {
|
||||
currentContainer = recoverCtx.targetName
|
||||
}
|
||||
currentInspect, err := recoverCtx.client.ContainerInspect(context.Background(), currentContainer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentMounts := make(map[string]container.MountPoint, len(currentInspect.Mounts))
|
||||
for _, item := range currentInspect.Mounts {
|
||||
currentMounts[item.Destination] = item
|
||||
}
|
||||
|
||||
for _, item := range recoverCtx.meta.Mounts {
|
||||
if item.Status != "backed_up" || item.BackupPath == "" || !item.RW {
|
||||
continue
|
||||
}
|
||||
backupPath := path.Join(recoverCtx.tmpDir, item.BackupPath)
|
||||
if !recoverCtx.fileOp.Stat(backupPath) {
|
||||
continue
|
||||
}
|
||||
sourcePath := item.Source
|
||||
if currentMount, ok := currentMounts[item.Destination]; ok {
|
||||
if currentMount.Source != "" {
|
||||
sourcePath = currentMount.Source
|
||||
}
|
||||
if item.Type == string(mount.TypeVolume) && item.Name == "" {
|
||||
item.Name = currentMount.Name
|
||||
}
|
||||
}
|
||||
if sourcePath == "" && item.Type == string(mount.TypeVolume) && item.Name != "" {
|
||||
volumeInfo, volumeErr := recoverCtx.client.VolumeInspect(context.Background(), item.Name)
|
||||
if volumeErr != nil {
|
||||
return volumeErr
|
||||
}
|
||||
sourcePath = volumeInfo.Mountpoint
|
||||
}
|
||||
if sourcePath == "" {
|
||||
continue
|
||||
}
|
||||
if err := restoreContainerMountData(recoverCtx.fileOp, backupPath, sourcePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func restoreContainerMountData(fileOp files.FileOp, backupPath, sourcePath string) error {
|
||||
if sourcePath == "/" {
|
||||
return fmt.Errorf("invalid mount source path /")
|
||||
}
|
||||
entries, err := os.ReadDir(backupPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(entries) == 1 && !entries[0].IsDir() && entries[0].Name() == path.Base(sourcePath) {
|
||||
if err := os.MkdirAll(path.Dir(sourcePath), os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.RemoveAll(sourcePath)
|
||||
if err := fileOp.CopyFile(path.Join(backupPath, entries[0].Name()), path.Dir(sourcePath)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = os.RemoveAll(sourcePath)
|
||||
if err := os.MkdirAll(sourcePath, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileOp.CopyDirWithNewName(backupPath, sourcePath, "."); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepStartRecoveredContainer(recoverCtx *containerRecoverContext) error {
|
||||
if !recoverCtx.shouldStart {
|
||||
return nil
|
||||
}
|
||||
containerID := recoverCtx.createdContainerID
|
||||
if containerID == "" {
|
||||
containerID = recoverCtx.targetName
|
||||
}
|
||||
return recoverCtx.client.ContainerStart(context.Background(), containerID, container.StartOptions{})
|
||||
}
|
||||
|
||||
func sanitizeContainerMountName(in string) string {
|
||||
name := strings.TrimSpace(in)
|
||||
name = strings.Trim(name, "/")
|
||||
name = strings.ReplaceAll(name, "/", "_")
|
||||
name = strings.ReplaceAll(name, ":", "_")
|
||||
if name == "" {
|
||||
return "root"
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -275,7 +275,8 @@ func (t *Task) Execute() error {
|
||||
}
|
||||
var err error
|
||||
t.Log(i18n.GetWithName("TaskStart", t.Name))
|
||||
for _, subTask := range t.SubTasks {
|
||||
for i := 0; i < len(t.SubTasks); i++ {
|
||||
subTask := t.SubTasks[i]
|
||||
t.Task.CurrentStep = subTask.StepAlias
|
||||
t.updateTask(t.Task)
|
||||
if err = subTask.Execute(); err == nil {
|
||||
|
||||
@@ -232,6 +232,36 @@ BuildCache: 'Build Cache'
|
||||
Volume: 'Volume'
|
||||
Network: "Network"
|
||||
PruneStart: 'Cleaning in progress'
|
||||
ContainerBackupPrepare: 'Prepare container backup workspace'
|
||||
ContainerBackupStop: 'Stop container before backup'
|
||||
ContainerBackupInspect: 'Backup container inspect and network'
|
||||
ContainerBackupMounts: 'Backup container mounts'
|
||||
ContainerBackupMeta: 'Write container backup metadata'
|
||||
ContainerBackupStart: 'Start container after backup'
|
||||
ContainerRecoverPrepare: 'Prepare container restore workspace'
|
||||
ContainerRecoverExtract: 'Extract container backup archive'
|
||||
ContainerRecoverParse: 'Load container backup metadata'
|
||||
ContainerRecoverCreate: 'Recreate container'
|
||||
ContainerRecoverMounts: 'Restore container mounts'
|
||||
ContainerRecoverStart: 'Start restored container'
|
||||
ContainerRecoverCleanup: 'Cleanup container restore workspace'
|
||||
ComposeBackupPrepare: 'Prepare compose backup workspace'
|
||||
ComposeBackupStop: 'Stop compose before backup'
|
||||
ComposeBackupFiles: 'Backup compose files'
|
||||
ComposeBackupContainers: 'Backup compose containers'
|
||||
ComposeBackupMeta: 'Write compose backup metadata'
|
||||
ComposeBackupStart: 'Start compose after backup'
|
||||
ComposeBackupCleanup: 'Cleanup compose backup workspace'
|
||||
ComposeRecoverPrepare: 'Prepare compose restore workspace'
|
||||
ComposeRecoverExtract: 'Extract compose backup archive'
|
||||
ComposeRecoverMeta: 'Load compose backup metadata'
|
||||
ComposeRecoverMetaLogName: 'Compose restore meta: name={{ .name }}'
|
||||
ComposeRecoverMetaLogPath: 'Compose restore meta: backupPath={{ .backupPath }} targetDir={{ .targetDir }}'
|
||||
ComposeRecoverMetaLogCount: 'Compose restore meta: files={{ .files }} containers={{ .containers }}'
|
||||
ComposeRecoverFiles: 'Restore compose files'
|
||||
ComposeRecoverContainers: 'Restore compose containers'
|
||||
ComposeRecoverRecord: 'Save compose record'
|
||||
ComposeRecoverCleanup: 'Cleanup compose restore workspace'
|
||||
|
||||
#runtime
|
||||
ErrFileNotExist: 'Source file not found: {{ .detail }}'
|
||||
|
||||
@@ -209,6 +209,36 @@ BuildCache: 'Caché de compilación'
|
||||
Volume: 'Volumen de almacenamiento'
|
||||
Network: 'Red'
|
||||
PruneStart: 'Limpiando en progreso espere...'
|
||||
ContainerBackupPrepare: 'Preparar espacio de respaldo del contenedor'
|
||||
ContainerBackupStop: 'Detener contenedor antes del respaldo'
|
||||
ContainerBackupInspect: 'Respaldar inspect y red del contenedor'
|
||||
ContainerBackupMounts: 'Respaldar montajes del contenedor'
|
||||
ContainerBackupMeta: 'Escribir metadatos del respaldo del contenedor'
|
||||
ContainerBackupStart: 'Iniciar contenedor después del respaldo'
|
||||
ContainerRecoverPrepare: 'Preparar espacio de restauración del contenedor'
|
||||
ContainerRecoverExtract: 'Extraer archivo de respaldo del contenedor'
|
||||
ContainerRecoverParse: 'Cargar metadatos del respaldo del contenedor'
|
||||
ContainerRecoverCreate: 'Recrear contenedor'
|
||||
ContainerRecoverMounts: 'Restaurar montajes del contenedor'
|
||||
ContainerRecoverStart: 'Iniciar contenedor restaurado'
|
||||
ContainerRecoverCleanup: 'Limpiar espacio de restauracion del contenedor'
|
||||
ComposeBackupPrepare: 'Preparar espacio de respaldo del compose'
|
||||
ComposeBackupStop: 'Detener compose antes del respaldo'
|
||||
ComposeBackupFiles: 'Respaldar archivos de compose'
|
||||
ComposeBackupContainers: 'Respaldar contenedores del compose'
|
||||
ComposeBackupMeta: 'Escribir metadatos del respaldo de compose'
|
||||
ComposeBackupStart: 'Iniciar compose después del respaldo'
|
||||
ComposeBackupCleanup: 'Limpiar espacio de respaldo del compose'
|
||||
ComposeRecoverPrepare: 'Preparar espacio de restauración del compose'
|
||||
ComposeRecoverExtract: 'Extraer archivo de respaldo de compose'
|
||||
ComposeRecoverMeta: 'Cargar metadatos del respaldo de compose'
|
||||
ComposeRecoverMetaLogName: 'Metadatos de restauracion de compose: nombre={{ .name }}'
|
||||
ComposeRecoverMetaLogPath: 'Metadatos de restauracion de compose: rutaRespaldo={{ .backupPath }} rutaDestino={{ .targetDir }}'
|
||||
ComposeRecoverMetaLogCount: 'Metadatos de restauracion de compose: archivos={{ .files }} contenedores={{ .containers }}'
|
||||
ComposeRecoverFiles: 'Restaurar archivos de compose'
|
||||
ComposeRecoverContainers: 'Restaurar contenedores del compose'
|
||||
ComposeRecoverRecord: 'Guardar registro de compose'
|
||||
ComposeRecoverCleanup: 'Limpiar espacio de restauracion de compose'
|
||||
ErrFileNotExist: 'El archivo {{ .detail }} no existe, verifique la integridad'
|
||||
ErrImageBuildErr: 'Fallo al construir la imagen'
|
||||
ErrImageExist: 'La imagen ya existe Modifique el nombre de la imagen.'
|
||||
|
||||
@@ -209,6 +209,36 @@ BuildCache: 'ビルドキャッシュ'
|
||||
Volume: 'ストレージボリューム'
|
||||
Network: 'ネットワーク'
|
||||
PruneStart: 'クリーンアップ中、しばらくお待ちください...'
|
||||
ContainerBackupPrepare: 'コンテナバックアップ作業領域を準備'
|
||||
ContainerBackupStop: 'バックアップ前にコンテナを停止'
|
||||
ContainerBackupInspect: 'コンテナ inspect とネットワーク情報をバックアップ'
|
||||
ContainerBackupMounts: 'コンテナのマウントデータをバックアップ'
|
||||
ContainerBackupMeta: 'コンテナバックアップのメタデータを書き込み'
|
||||
ContainerBackupStart: 'バックアップ後にコンテナを起動'
|
||||
ContainerRecoverPrepare: 'コンテナ復元作業領域を準備'
|
||||
ContainerRecoverExtract: 'コンテナバックアップを展開'
|
||||
ContainerRecoverParse: 'コンテナバックアップメタデータを読み込み'
|
||||
ContainerRecoverCreate: 'コンテナを再作成'
|
||||
ContainerRecoverMounts: 'コンテナマウントを復元'
|
||||
ContainerRecoverStart: '復元済みコンテナを起動'
|
||||
ContainerRecoverCleanup: 'コンテナ復元作業領域をクリーンアップ'
|
||||
ComposeBackupPrepare: 'Compose バックアップ作業領域を準備'
|
||||
ComposeBackupStop: 'バックアップ前に Compose を停止'
|
||||
ComposeBackupFiles: 'Compose ファイルをバックアップ'
|
||||
ComposeBackupContainers: 'Compose コンテナをバックアップ'
|
||||
ComposeBackupMeta: 'Compose バックアップのメタデータを書き込み'
|
||||
ComposeBackupStart: 'バックアップ後に Compose を起動'
|
||||
ComposeBackupCleanup: 'Compose バックアップ作業領域をクリーンアップ'
|
||||
ComposeRecoverPrepare: 'Compose 復元作業領域を準備'
|
||||
ComposeRecoverExtract: 'Compose バックアップを展開'
|
||||
ComposeRecoverMeta: 'Compose バックアップメタデータを読み込み'
|
||||
ComposeRecoverMetaLogName: 'Compose 復元メタ情報: 名前={{ .name }}'
|
||||
ComposeRecoverMetaLogPath: 'Compose 復元メタ情報: バックアップパス={{ .backupPath }} 復元先={{ .targetDir }}'
|
||||
ComposeRecoverMetaLogCount: 'Compose 復元メタ情報: ファイル={{ .files }} コンテナ={{ .containers }}'
|
||||
ComposeRecoverFiles: 'Compose ファイルを復元'
|
||||
ComposeRecoverContainers: 'Compose コンテナを復元'
|
||||
ComposeRecoverRecord: 'Compose 記録を保存'
|
||||
ComposeRecoverCleanup: 'Compose 復元作業領域をクリーンアップ'
|
||||
ErrFileNotExist: '{{ .detail }} ファイルが存在しません。ソース ファイルの整合性を確認してください。'
|
||||
ErrImageBuildErr: 'イメージのビルドに失敗しました'
|
||||
ErrImageExist: 'イメージはすでに存在します!イメージ名を変更してください。'
|
||||
|
||||
@@ -209,6 +209,36 @@ BuildCache: '빌드 캐시'
|
||||
Volume: '스토리지 볼륨'
|
||||
Network: '네트워크'
|
||||
PruneStart: '정리 중입니다. 잠시만 기다려주세요...'
|
||||
ContainerBackupPrepare: '컨테이너 백업 작업 공간 준비'
|
||||
ContainerBackupStop: '백업 전에 컨테이너 중지'
|
||||
ContainerBackupInspect: '컨테이너 inspect 및 네트워크 정보 백업'
|
||||
ContainerBackupMounts: '컨테이너 마운트 데이터 백업'
|
||||
ContainerBackupMeta: '컨테이너 백업 메타데이터 작성'
|
||||
ContainerBackupStart: '백업 후 컨테이너 시작'
|
||||
ContainerRecoverPrepare: '컨테이너 복원 작업 공간 준비'
|
||||
ContainerRecoverExtract: '컨테이너 백업 파일 압축 해제'
|
||||
ContainerRecoverParse: '컨테이너 백업 메타데이터 로드'
|
||||
ContainerRecoverCreate: '컨테이너 재생성'
|
||||
ContainerRecoverMounts: '컨테이너 마운트 데이터 복원'
|
||||
ContainerRecoverStart: '복원된 컨테이너 시작'
|
||||
ContainerRecoverCleanup: '컨테이너 복원 작업 공간 정리'
|
||||
ComposeBackupPrepare: 'Compose 백업 작업 공간 준비'
|
||||
ComposeBackupStop: '백업 전에 Compose 중지'
|
||||
ComposeBackupFiles: 'Compose 파일 백업'
|
||||
ComposeBackupContainers: 'Compose 컨테이너 백업'
|
||||
ComposeBackupMeta: 'Compose 백업 메타데이터 작성'
|
||||
ComposeBackupStart: '백업 후 Compose 시작'
|
||||
ComposeBackupCleanup: 'Compose 백업 작업 공간 정리'
|
||||
ComposeRecoverPrepare: 'Compose 복원 작업 공간 준비'
|
||||
ComposeRecoverExtract: 'Compose 백업 파일 압축 해제'
|
||||
ComposeRecoverMeta: 'Compose 백업 메타데이터 로드'
|
||||
ComposeRecoverMetaLogName: 'Compose 복원 메타: 이름={{ .name }}'
|
||||
ComposeRecoverMetaLogPath: 'Compose 복원 메타: 백업경로={{ .backupPath }} 대상경로={{ .targetDir }}'
|
||||
ComposeRecoverMetaLogCount: 'Compose 복원 메타: 파일={{ .files }} 컨테이너={{ .containers }}'
|
||||
ComposeRecoverFiles: 'Compose 파일 복원'
|
||||
ComposeRecoverContainers: 'Compose 컨테이너 복원'
|
||||
ComposeRecoverRecord: 'Compose 기록 저장'
|
||||
ComposeRecoverCleanup: 'Compose 복원 작업 공간 정리'
|
||||
ErrFileNotExist: '{{ .detail }} 파일이 존재하지 않습니다 소스 파일의 무결성을 확인하세요'
|
||||
ErrImageBuildErr: '이미지 빌드 실패'
|
||||
ErrImageExist: '이미지가 이미 존재합니다 이미지 이름을 수정하세요.'
|
||||
|
||||
@@ -209,6 +209,36 @@ BuildCache: 'Cache binaan'
|
||||
Volume: 'Jilid storan'
|
||||
Network: 'Rangkaian'
|
||||
PruneStart: 'Pembersihan sedang dijalankan, sila tunggu...'
|
||||
ContainerBackupPrepare: 'Sediakan ruang kerja sandaran kontena'
|
||||
ContainerBackupStop: 'Hentikan kontena sebelum sandaran'
|
||||
ContainerBackupInspect: 'Sandarkan inspect dan rangkaian kontena'
|
||||
ContainerBackupMounts: 'Sandarkan mount kontena'
|
||||
ContainerBackupMeta: 'Tulis metadata sandaran kontena'
|
||||
ContainerBackupStart: 'Mulakan kontena selepas sandaran'
|
||||
ContainerRecoverPrepare: 'Sediakan ruang kerja pemulihan kontena'
|
||||
ContainerRecoverExtract: 'Ekstrak arkib sandaran kontena'
|
||||
ContainerRecoverParse: 'Muat metadata sandaran kontena'
|
||||
ContainerRecoverCreate: 'Cipta semula kontena'
|
||||
ContainerRecoverMounts: 'Pulihkan mount kontena'
|
||||
ContainerRecoverStart: 'Mulakan kontena dipulihkan'
|
||||
ContainerRecoverCleanup: 'Bersihkan ruang kerja pemulihan kontena'
|
||||
ComposeBackupPrepare: 'Sediakan ruang kerja sandaran compose'
|
||||
ComposeBackupStop: 'Hentikan compose sebelum sandaran'
|
||||
ComposeBackupFiles: 'Sandarkan fail compose'
|
||||
ComposeBackupContainers: 'Sandarkan kontena compose'
|
||||
ComposeBackupMeta: 'Tulis metadata sandaran compose'
|
||||
ComposeBackupStart: 'Mulakan compose selepas sandaran'
|
||||
ComposeBackupCleanup: 'Bersihkan ruang kerja sandaran compose'
|
||||
ComposeRecoverPrepare: 'Sediakan ruang kerja pemulihan compose'
|
||||
ComposeRecoverExtract: 'Ekstrak arkib sandaran compose'
|
||||
ComposeRecoverMeta: 'Muat metadata sandaran compose'
|
||||
ComposeRecoverMetaLogName: 'Metadata pemulihan compose: nama={{ .name }}'
|
||||
ComposeRecoverMetaLogPath: 'Metadata pemulihan compose: laluanSandaran={{ .backupPath }} laluanSasaran={{ .targetDir }}'
|
||||
ComposeRecoverMetaLogCount: 'Metadata pemulihan compose: fail={{ .files }} kontena={{ .containers }}'
|
||||
ComposeRecoverFiles: 'Pulihkan fail compose'
|
||||
ComposeRecoverContainers: 'Pulihkan kontena compose'
|
||||
ComposeRecoverRecord: 'Simpan rekod compose'
|
||||
ComposeRecoverCleanup: 'Bersihkan ruang kerja pemulihan compose'
|
||||
ErrFileNotExist: 'Fail {{ .detail }} tidak wujud Sila semak integriti fail sumber'
|
||||
ErrImageBuildErr: 'Pembinaan imej gagal'
|
||||
ErrImageExist: 'Imej sudah wujud Sila ubah nama imej.'
|
||||
|
||||
@@ -209,6 +209,36 @@ BuildCache: 'Cache de construção'
|
||||
Volume: 'Volume de armazenamento'
|
||||
Network: 'Rede'
|
||||
PruneStart: 'Limpando em andamento aguarde...'
|
||||
ContainerBackupPrepare: 'Preparar área de backup do contêiner'
|
||||
ContainerBackupStop: 'Parar contêiner antes do backup'
|
||||
ContainerBackupInspect: 'Fazer backup do inspect e rede do contêiner'
|
||||
ContainerBackupMounts: 'Fazer backup dos mounts do contêiner'
|
||||
ContainerBackupMeta: 'Escrever metadados do backup do contêiner'
|
||||
ContainerBackupStart: 'Iniciar contêiner após o backup'
|
||||
ContainerRecoverPrepare: 'Preparar área de restauração do contêiner'
|
||||
ContainerRecoverExtract: 'Extrair arquivo de backup do contêiner'
|
||||
ContainerRecoverParse: 'Carregar metadados do backup do contêiner'
|
||||
ContainerRecoverCreate: 'Recriar contêiner'
|
||||
ContainerRecoverMounts: 'Restaurar mounts do contêiner'
|
||||
ContainerRecoverStart: 'Iniciar contêiner restaurado'
|
||||
ContainerRecoverCleanup: 'Limpar área de restauração do contêiner'
|
||||
ComposeBackupPrepare: 'Preparar área de backup do compose'
|
||||
ComposeBackupStop: 'Parar compose antes do backup'
|
||||
ComposeBackupFiles: 'Fazer backup dos arquivos do compose'
|
||||
ComposeBackupContainers: 'Fazer backup dos contêineres do compose'
|
||||
ComposeBackupMeta: 'Escrever metadados do backup do compose'
|
||||
ComposeBackupStart: 'Iniciar compose após o backup'
|
||||
ComposeBackupCleanup: 'Limpar área de backup do compose'
|
||||
ComposeRecoverPrepare: 'Preparar área de restauração do compose'
|
||||
ComposeRecoverExtract: 'Extrair arquivo de backup do compose'
|
||||
ComposeRecoverMeta: 'Carregar metadados do backup do compose'
|
||||
ComposeRecoverMetaLogName: 'Metadados da restauracao do compose: nome={{ .name }}'
|
||||
ComposeRecoverMetaLogPath: 'Metadados da restauracao do compose: caminhoBackup={{ .backupPath }} caminhoDestino={{ .targetDir }}'
|
||||
ComposeRecoverMetaLogCount: 'Metadados da restauracao do compose: arquivos={{ .files }} containers={{ .containers }}'
|
||||
ComposeRecoverFiles: 'Restaurar arquivos do compose'
|
||||
ComposeRecoverContainers: 'Restaurar contêineres do compose'
|
||||
ComposeRecoverRecord: 'Salvar registro do compose'
|
||||
ComposeRecoverCleanup: 'Limpar área de restauração do compose'
|
||||
ErrFileNotExist: 'O arquivo {{ .detail }} não existe Verifique a integridade do arquivo de origem'
|
||||
ErrImageBuildErr: 'Falha na criação da imagem'
|
||||
ErrImageExist: 'A imagem já existe, modifique o nome da imagem.'
|
||||
|
||||
@@ -209,6 +209,36 @@ BuildCache: 'Кэш сборки'
|
||||
Volume: 'Том хранилища'
|
||||
Network: 'Сеть'
|
||||
PruneStart: 'Очистка выполняется, пожалуйста, подождите...'
|
||||
ContainerBackupPrepare: 'Подготовить рабочую область резервной копии контейнера'
|
||||
ContainerBackupStop: 'Остановить контейнер перед резервным копированием'
|
||||
ContainerBackupInspect: 'Сделать резервную копию inspect и сети контейнера'
|
||||
ContainerBackupMounts: 'Сделать резервную копию точек монтирования контейнера'
|
||||
ContainerBackupMeta: 'Записать метаданные резервной копии контейнера'
|
||||
ContainerBackupStart: 'Запустить контейнер после резервного копирования'
|
||||
ContainerRecoverPrepare: 'Подготовить рабочую область восстановления контейнера'
|
||||
ContainerRecoverExtract: 'Распаковать архив резервной копии контейнера'
|
||||
ContainerRecoverParse: 'Загрузить метаданные резервной копии контейнера'
|
||||
ContainerRecoverCreate: 'Пересоздать контейнер'
|
||||
ContainerRecoverMounts: 'Восстановить точки монтирования контейнера'
|
||||
ContainerRecoverStart: 'Запустить восстановленный контейнер'
|
||||
ContainerRecoverCleanup: 'Очистить рабочую область восстановления контейнера'
|
||||
ComposeBackupPrepare: 'Подготовить рабочую область резервной копии compose'
|
||||
ComposeBackupStop: 'Остановить compose перед резервным копированием'
|
||||
ComposeBackupFiles: 'Сделать резервную копию файлов compose'
|
||||
ComposeBackupContainers: 'Сделать резервную копию контейнеров compose'
|
||||
ComposeBackupMeta: 'Записать метаданные резервной копии compose'
|
||||
ComposeBackupStart: 'Запустить compose после резервного копирования'
|
||||
ComposeBackupCleanup: 'Очистить рабочую область резервного копирования compose'
|
||||
ComposeRecoverPrepare: 'Подготовить рабочую область восстановления compose'
|
||||
ComposeRecoverExtract: 'Распаковать архив резервной копии compose'
|
||||
ComposeRecoverMeta: 'Загрузить метаданные резервной копии compose'
|
||||
ComposeRecoverMetaLogName: 'Метаданные восстановления compose: имя={{ .name }}'
|
||||
ComposeRecoverMetaLogPath: 'Метаданные восстановления compose: путьБэкапа={{ .backupPath }} путьНазначения={{ .targetDir }}'
|
||||
ComposeRecoverMetaLogCount: 'Метаданные восстановления compose: файлов={{ .files }} контейнеров={{ .containers }}'
|
||||
ComposeRecoverFiles: 'Восстановить файлы compose'
|
||||
ComposeRecoverContainers: 'Восстановить контейнеры compose'
|
||||
ComposeRecoverRecord: 'Сохранить запись compose'
|
||||
ComposeRecoverCleanup: 'Очистить рабочую область восстановления compose'
|
||||
ErrFileNotExist: 'Файл {{ .detail }} не существует Проверьте целостность исходного файла'
|
||||
ErrImageBuildErr: 'Сборка образа не удалась'
|
||||
ErrImageExist: 'Изображение уже существует Пожалуйста, измените имя изображения.'
|
||||
|
||||
@@ -209,6 +209,36 @@ BuildCache: 'Derleme önbelleği'
|
||||
Volume: 'Depolama hacmi'
|
||||
Network: 'Ağ'
|
||||
PruneStart: 'Temizlik devam ediyor, lütfen bekleyin...'
|
||||
ContainerBackupPrepare: 'Konteyner yedekleme çalışma alanını hazırla'
|
||||
ContainerBackupStop: 'Yedeklemeden önce konteyneri durdur'
|
||||
ContainerBackupInspect: 'Konteyner inspect ve ağ bilgisini yedekle'
|
||||
ContainerBackupMounts: 'Konteyner mount verilerini yedekle'
|
||||
ContainerBackupMeta: 'Konteyner yedekleme meta verisini yaz'
|
||||
ContainerBackupStart: 'Yedeklemeden sonra konteyneri başlat'
|
||||
ContainerRecoverPrepare: 'Konteyner geri yükleme çalışma alanını hazırla'
|
||||
ContainerRecoverExtract: 'Konteyner yedek arşivini çıkar'
|
||||
ContainerRecoverParse: 'Konteyner yedek meta verisini yükle'
|
||||
ContainerRecoverCreate: 'Konteyneri yeniden oluştur'
|
||||
ContainerRecoverMounts: 'Konteyner mount verilerini geri yükle'
|
||||
ContainerRecoverStart: 'Geri yüklenen konteyneri başlat'
|
||||
ContainerRecoverCleanup: 'Konteyner geri yükleme çalışma alanını temizle'
|
||||
ComposeBackupPrepare: 'Compose yedekleme çalışma alanını hazırla'
|
||||
ComposeBackupStop: 'Yedeklemeden önce compose’u durdur'
|
||||
ComposeBackupFiles: 'Compose dosyalarını yedekle'
|
||||
ComposeBackupContainers: 'Compose konteynerlerini yedekle'
|
||||
ComposeBackupMeta: 'Compose yedek meta verisini yaz'
|
||||
ComposeBackupStart: 'Yedeklemeden sonra compose’u başlat'
|
||||
ComposeBackupCleanup: 'Compose yedekleme çalışma alanını temizle'
|
||||
ComposeRecoverPrepare: 'Compose geri yükleme çalışma alanını hazırla'
|
||||
ComposeRecoverExtract: 'Compose yedek arşivini çıkar'
|
||||
ComposeRecoverMeta: 'Compose yedek meta verisini yükle'
|
||||
ComposeRecoverMetaLogName: 'Compose geri yükleme meta: ad={{ .name }}'
|
||||
ComposeRecoverMetaLogPath: 'Compose geri yükleme meta: yedekYolu={{ .backupPath }} hedefYol={{ .targetDir }}'
|
||||
ComposeRecoverMetaLogCount: 'Compose geri yükleme meta: dosya={{ .files }} konteyner={{ .containers }}'
|
||||
ComposeRecoverFiles: 'Compose dosyalarını geri yükle'
|
||||
ComposeRecoverContainers: 'Compose konteynerlerini geri yükle'
|
||||
ComposeRecoverRecord: 'Compose kaydını sakla'
|
||||
ComposeRecoverCleanup: 'Compose geri yükleme çalışma alanını temizle'
|
||||
ErrFileNotExist: '{{ .detail }} dosyası mevcut değil Lütfen kaynak dosyanın bütünlüğünü kontrol edin'
|
||||
ErrImageBuildErr: 'Image yapı başarısız'
|
||||
ErrImageExist: 'Görüntü zaten var Lütfen görüntü adını değiştirin.'
|
||||
|
||||
@@ -209,6 +209,36 @@ BuildCache: '構建快取'
|
||||
Volume: '磁碟區'
|
||||
Network: '網路'
|
||||
PruneStart: '清理中,請稍候...'
|
||||
ContainerBackupPrepare: '準備容器備份工作目錄'
|
||||
ContainerBackupStop: '備份前停止容器'
|
||||
ContainerBackupInspect: '備份容器 Inspect 與網路資訊'
|
||||
ContainerBackupMounts: '備份容器掛載資料'
|
||||
ContainerBackupMeta: '寫入容器備份中繼資訊'
|
||||
ContainerBackupStart: '備份後啟動容器'
|
||||
ContainerRecoverPrepare: '準備容器還原工作目錄'
|
||||
ContainerRecoverExtract: '解壓容器備份檔案'
|
||||
ContainerRecoverParse: '載入容器備份中繼資訊'
|
||||
ContainerRecoverCreate: '重建容器'
|
||||
ContainerRecoverMounts: '還原容器掛載資料'
|
||||
ContainerRecoverStart: '啟動已還原容器'
|
||||
ContainerRecoverCleanup: '清理容器還原工作目錄'
|
||||
ComposeBackupPrepare: '準備編排備份工作目錄'
|
||||
ComposeBackupStop: '備份前停止編排'
|
||||
ComposeBackupFiles: '備份編排檔案'
|
||||
ComposeBackupContainers: '備份編排容器'
|
||||
ComposeBackupMeta: '寫入編排備份中繼資訊'
|
||||
ComposeBackupStart: '備份後啟動編排'
|
||||
ComposeBackupCleanup: '清理編排備份工作目錄'
|
||||
ComposeRecoverPrepare: '準備編排還原工作目錄'
|
||||
ComposeRecoverExtract: '解壓編排備份檔案'
|
||||
ComposeRecoverMeta: '載入編排備份中繼資訊'
|
||||
ComposeRecoverMetaLogName: '編排還原中繼資訊:名稱={{ .name }}'
|
||||
ComposeRecoverMetaLogPath: '編排還原中繼資訊:備份路徑={{ .backupPath }} 目標路徑={{ .targetDir }}'
|
||||
ComposeRecoverMetaLogCount: '編排還原中繼資訊:檔案數={{ .files }} 容器數={{ .containers }}'
|
||||
ComposeRecoverFiles: '還原編排檔案'
|
||||
ComposeRecoverContainers: '還原編排容器'
|
||||
ComposeRecoverRecord: '保存編排記錄'
|
||||
ComposeRecoverCleanup: '清理編排還原工作目錄'
|
||||
ErrFileNotExist: '{{ .detail }} 檔案不存在!請檢查來源檔案完整性!'
|
||||
ErrImageBuildErr: '映像build 失敗'
|
||||
ErrImageExist: '映像已存在!請修改映像名稱。'
|
||||
|
||||
@@ -232,6 +232,36 @@ BuildCache: "构建缓存"
|
||||
Volume: "存储卷"
|
||||
Network: "网络"
|
||||
PruneStart: "清理中,请稍候..."
|
||||
ContainerBackupPrepare: "准备容器备份工作目录"
|
||||
ContainerBackupStop: "备份前停止容器"
|
||||
ContainerBackupInspect: "备份容器 Inspect 与网络信息"
|
||||
ContainerBackupMounts: "备份容器挂载数据"
|
||||
ContainerBackupMeta: "写入容器备份元信息"
|
||||
ContainerBackupStart: "备份后启动容器"
|
||||
ContainerRecoverPrepare: "准备容器恢复工作目录"
|
||||
ContainerRecoverExtract: "解压容器备份文件"
|
||||
ContainerRecoverParse: "加载容器备份元信息"
|
||||
ContainerRecoverCreate: "重建容器"
|
||||
ContainerRecoverMounts: "恢复容器挂载数据"
|
||||
ContainerRecoverStart: "启动已恢复容器"
|
||||
ContainerRecoverCleanup: "清理容器恢复工作目录"
|
||||
ComposeBackupPrepare: "准备编排备份工作目录"
|
||||
ComposeBackupStop: "备份前停止编排"
|
||||
ComposeBackupFiles: "备份编排文件"
|
||||
ComposeBackupContainers: "备份编排容器"
|
||||
ComposeBackupMeta: "写入编排备份元信息"
|
||||
ComposeBackupStart: "备份后启动编排"
|
||||
ComposeBackupCleanup: "清理编排备份工作目录"
|
||||
ComposeRecoverPrepare: "准备编排恢复工作目录"
|
||||
ComposeRecoverExtract: "解压编排备份文件"
|
||||
ComposeRecoverMeta: "加载编排备份元信息"
|
||||
ComposeRecoverMetaLogName: "编排恢复元信息:名称={{ .name }}"
|
||||
ComposeRecoverMetaLogPath: "编排恢复元信息:备份路径={{ .backupPath }} 目标路径={{ .targetDir }}"
|
||||
ComposeRecoverMetaLogCount: "编排恢复元信息:文件数={{ .files }} 容器数={{ .containers }}"
|
||||
ComposeRecoverFiles: "恢复编排文件"
|
||||
ComposeRecoverContainers: "恢复编排容器"
|
||||
ComposeRecoverRecord: "保存编排记录"
|
||||
ComposeRecoverCleanup: "清理编排恢复工作目录"
|
||||
|
||||
#runtime
|
||||
ErrFileNotExist: "{{ .detail }} 文件不存在!请检查源文件完整性!"
|
||||
|
||||
@@ -93,6 +93,7 @@ export namespace Backup {
|
||||
detailName: string;
|
||||
secret: string;
|
||||
taskID: string;
|
||||
stopBefore?: boolean;
|
||||
}
|
||||
export interface Recover {
|
||||
downloadAccountID: number;
|
||||
|
||||
@@ -52,8 +52,9 @@ export const handleRecover = (params: Backup.Recover, node?: string) => {
|
||||
const query = node ? `?operateNode=${node}` : '';
|
||||
return http.post(`/backups/recover${query}`, params, TimeoutEnum.T_10M);
|
||||
};
|
||||
export const handleRecoverByUpload = (params: Backup.Recover) => {
|
||||
return http.post(`/backups/recover/byupload`, params, TimeoutEnum.T_10M);
|
||||
export const handleRecoverByUpload = (params: Backup.Recover, node?: string) => {
|
||||
const query = node ? `?operateNode=${node}` : '';
|
||||
return http.post(`/backups/recover/byupload${query}`, params, TimeoutEnum.T_10M);
|
||||
};
|
||||
export const downloadBackupRecord = (params: Backup.RecordDownload, node?: string) => {
|
||||
const query = node ? `?operateNode=${node}` : '';
|
||||
@@ -67,8 +68,9 @@ export const updateRecordDescription = (id: Number, description: String, node?:
|
||||
const query = node ? `?operateNode=${node}` : '';
|
||||
return http.post(`/backups/record/description/update${query}`, { id: id, description: description });
|
||||
};
|
||||
export const uploadByRecover = (filePath: string, targetDir: String) => {
|
||||
return http.post(`/backups/upload`, { filePath: filePath, targetDir: targetDir });
|
||||
export const uploadByRecover = (filePath: string, targetDir: String, node?: string) => {
|
||||
const query = node ? `?operateNode=${node}` : '';
|
||||
return http.post(`/backups/upload${query}`, { filePath: filePath, targetDir: targetDir });
|
||||
};
|
||||
export const searchBackupRecords = (params: Backup.SearchBackupRecord, node?: string) => {
|
||||
const query = node ? `?operateNode=${node}` : '';
|
||||
|
||||
@@ -60,8 +60,9 @@ export const listAppNodes = () => {
|
||||
};
|
||||
|
||||
// agent
|
||||
export const loadBaseDir = () => {
|
||||
return http.get<string>(`/settings/basedir`);
|
||||
export const loadBaseDir = (node?: string) => {
|
||||
const query = node ? `?operateNode=${node}` : '';
|
||||
return http.get<string>(`/settings/basedir${query}`);
|
||||
};
|
||||
export const loadDaemonJsonPath = () => {
|
||||
return http.get<string>(`/settings/daemonjson`, {});
|
||||
|
||||
@@ -27,7 +27,11 @@
|
||||
style="width: 100%"
|
||||
>
|
||||
<template #toolbar>
|
||||
<el-button type="primary" :disabled="status && status != 'Running'" @click="onBackup()">
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="status && status.toLowerCase() != 'running'"
|
||||
@click="onBackup()"
|
||||
>
|
||||
{{ $t('commons.button.backup') }}
|
||||
</el-button>
|
||||
<el-button type="primary" plain :disabled="selects.length === 0" @click="onBatchDelete(null)">
|
||||
@@ -106,6 +110,16 @@
|
||||
{{ $t('commons.msg.' + (isBackup ? 'backupHelper' : 'recoverHelper'), [name + '( ' + detailName + ' )']) }}
|
||||
</el-alert>
|
||||
<el-form class="mt-5" ref="backupForm" @submit.prevent label-position="top" v-loading="loading">
|
||||
<el-form-item v-if="isBackup && (type === 'container' || type === 'compose')">
|
||||
<el-checkbox v-model="stopBefore">
|
||||
{{
|
||||
type === 'container'
|
||||
? $t('container.stopContainerBeforeBackup')
|
||||
: $t('container.stopComposeBeforeBackup')
|
||||
}}
|
||||
</el-checkbox>
|
||||
<span class="input-help">{{ $t('container.stopBeforeBackupHelper') }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('setting.compressPassword')">
|
||||
<el-input v-model="secret" :placeholder="$t('setting.backupRecoverMessage')" />
|
||||
</el-form-item>
|
||||
@@ -213,6 +227,7 @@ const description = ref();
|
||||
const timeoutItem = ref(30);
|
||||
const timeoutUnit = ref('m');
|
||||
const node = ref();
|
||||
const stopBefore = ref(false);
|
||||
|
||||
const open = ref();
|
||||
const isBackup = ref();
|
||||
@@ -324,6 +339,7 @@ const backup = async () => {
|
||||
taskID: taskID,
|
||||
description: description.value,
|
||||
args: args.value,
|
||||
stopBefore: stopBefore.value,
|
||||
};
|
||||
loading.value = true;
|
||||
await handleBackup(params, node.value)
|
||||
@@ -366,6 +382,7 @@ const onBackup = async () => {
|
||||
description.value = '';
|
||||
secret.value = '';
|
||||
args.value = [];
|
||||
stopBefore.value = false;
|
||||
isBackup.value = true;
|
||||
open.value = true;
|
||||
};
|
||||
|
||||
@@ -17,8 +17,12 @@
|
||||
{{ $t('database.formatHelper', [remark]) }}
|
||||
</li>
|
||||
<li v-if="isDb()">{{ $t('database.supportUpType') }}</li>
|
||||
<li v-if="!isDb()">{{ $t('website.websiteBackupWarn') }}</li>
|
||||
<li v-if="!isDb()">{{ $t('website.supportUpType', [type]) }}</li>
|
||||
<li v-if="type === 'website' || type === 'app'">
|
||||
{{ $t('website.websiteBackupWarn') }}
|
||||
</li>
|
||||
<li v-if="type === 'container'">{{ $t('container.importContainerBackupTip') }}</li>
|
||||
<li v-else-if="type === 'compose'">{{ $t('container.importComposeBackupTip') }}</li>
|
||||
<li v-else-if="!isDb()">{{ $t('website.supportUpType', [type]) }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
</el-alert>
|
||||
@@ -86,7 +90,7 @@
|
||||
|
||||
<DialogPro
|
||||
v-model="recoverDialog"
|
||||
:title="$t('commons.button.recover') + ' - ' + name"
|
||||
:title="name ? $t('commons.button.recover') + ' - ' + name : $t('commons.button.recover')"
|
||||
@close="handleRecoverClose"
|
||||
size="small"
|
||||
>
|
||||
@@ -125,7 +129,7 @@
|
||||
|
||||
<OpDialog ref="opRef" @search="search" />
|
||||
<FileList ref="fileRef" @choose="loadFile" />
|
||||
<TaskLog ref="taskLogRef" @close="search" />
|
||||
<TaskLog ref="taskLogRef" @close="handleTaskLogClose" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -141,11 +145,14 @@ import { MsgError, MsgSuccess } from '@/utils/message';
|
||||
import { handleRecoverByUpload, uploadByRecover } from '@/api/modules/backup';
|
||||
import TaskLog from '@/components/log/task/index.vue';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
interface DialogProps {
|
||||
type: string;
|
||||
name: string;
|
||||
detailName: string;
|
||||
remark: string;
|
||||
node?: string;
|
||||
}
|
||||
const loading = ref();
|
||||
const fileRef = ref();
|
||||
@@ -171,6 +178,7 @@ const secret = ref();
|
||||
const timeoutItem = ref(30);
|
||||
const timeoutUnit = ref('m');
|
||||
const taskLogRef = ref();
|
||||
const node = ref();
|
||||
|
||||
const recoverDialog = ref();
|
||||
|
||||
@@ -179,8 +187,9 @@ const acceptParams = async (params: DialogProps): Promise<void> => {
|
||||
name.value = params.name;
|
||||
detailName.value = params.detailName;
|
||||
remark.value = params.remark;
|
||||
node.value = params.node;
|
||||
|
||||
const pathRes = await loadBaseDir();
|
||||
const pathRes = await loadBaseDir(node.value);
|
||||
switch (type.value) {
|
||||
case 'mysql':
|
||||
case 'mariadb':
|
||||
@@ -201,6 +210,23 @@ const acceptParams = async (params: DialogProps): Promise<void> => {
|
||||
case 'app':
|
||||
title.value = name.value;
|
||||
baseDir.value = `${pathRes.data}/uploads/app/${type.value}/${name.value}/`;
|
||||
break;
|
||||
case 'container':
|
||||
title.value = name.value || i18n.global.t('menu.container');
|
||||
if (name.value) {
|
||||
baseDir.value = `${pathRes.data}/uploads/container/${name.value}/`;
|
||||
} else {
|
||||
baseDir.value = `${pathRes.data}/uploads/container/import/`;
|
||||
}
|
||||
break;
|
||||
case 'compose':
|
||||
title.value = name.value || i18n.global.t('container.compose');
|
||||
if (name.value) {
|
||||
baseDir.value = `${pathRes.data}/uploads/compose/${name.value}/`;
|
||||
} else {
|
||||
baseDir.value = `${pathRes.data}/uploads/compose/import/`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
uploadOpen.value = true;
|
||||
search();
|
||||
@@ -236,6 +262,14 @@ const beforeUpload = (fileName: string) => {
|
||||
const allowedExtensions = ['.tar.gz'];
|
||||
const isValidFile = allowedExtensions.some((ext) => itemName.endsWith(ext));
|
||||
if (!isValidFile) {
|
||||
if (type.value === 'compose') {
|
||||
MsgError(i18n.global.t('container.importComposeBackupTip'));
|
||||
return false;
|
||||
}
|
||||
if (type.value === 'container') {
|
||||
MsgError(i18n.global.t('container.importContainerBackupTip'));
|
||||
return false;
|
||||
}
|
||||
MsgError(i18n.global.t('website.supportUpType'));
|
||||
return false;
|
||||
}
|
||||
@@ -255,7 +289,7 @@ const loadFile = async (path: string) => {
|
||||
confirmButtonText: i18n.global.t('commons.button.confirm'),
|
||||
cancelButtonText: i18n.global.t('commons.button.cancel'),
|
||||
}).then(async () => {
|
||||
uploadByRecover(path, baseDir.value)
|
||||
uploadByRecover(path, baseDir.value, node.value)
|
||||
.then(() => {
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
search();
|
||||
@@ -267,7 +301,7 @@ const loadFile = async (path: string) => {
|
||||
};
|
||||
|
||||
const openTaskLog = (taskID: string) => {
|
||||
taskLogRef.value.openWithTaskID(taskID);
|
||||
taskLogRef.value.openWithTaskID(taskID, true, node.value);
|
||||
};
|
||||
|
||||
const onHandleRecover = async () => {
|
||||
@@ -282,7 +316,7 @@ const onHandleRecover = async () => {
|
||||
timeout: timeoutItem.value === -1 ? -1 : transferTimeToSecond(timeoutItem.value + timeoutUnit.value),
|
||||
};
|
||||
loading.value = true;
|
||||
await handleRecoverByUpload(params)
|
||||
await handleRecoverByUpload(params, node.value)
|
||||
.then(() => {
|
||||
loading.value = false;
|
||||
handleUploadClose();
|
||||
@@ -343,6 +377,10 @@ const handleUploadClose = () => {
|
||||
uploaderFiles.value = [];
|
||||
uploadRef.value!.clearFiles();
|
||||
uploadOpen.value = false;
|
||||
emit('close');
|
||||
};
|
||||
const handleTaskLogClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleRecoverClose = () => {
|
||||
|
||||
@@ -839,6 +839,12 @@ const message = {
|
||||
},
|
||||
container: {
|
||||
createByCommand: 'Create by command',
|
||||
importContainerBackupTip: 'Import container backup file, only .tar.gz is supported',
|
||||
importComposeBackupTip: 'Import compose backup file, only .tar.gz is supported',
|
||||
stopContainerBeforeBackup: 'Stop container before backup',
|
||||
stopComposeBeforeBackup: 'Stop compose before backup',
|
||||
stopBeforeBackupHelper:
|
||||
'When enabled, the container or compose service is stopped before backup and automatically restored after completion to ensure data consistency.',
|
||||
commandInput: 'Command input',
|
||||
commandRule: 'Enter the correct docker run container creation command!',
|
||||
commandHelper: 'This command will run on the server to create the container. Continue?',
|
||||
|
||||
@@ -851,6 +851,12 @@ const message = {
|
||||
},
|
||||
container: {
|
||||
createByCommand: 'Crear por comando',
|
||||
importContainerBackupTip: 'Importar archivo de copia de seguridad de contenedor, solo se admite .tar.gz',
|
||||
importComposeBackupTip: 'Importar archivo de copia de seguridad de compose, solo se admite .tar.gz',
|
||||
stopContainerBeforeBackup: 'Detener contenedor antes de la copia de seguridad',
|
||||
stopComposeBeforeBackup: 'Detener compose antes de la copia de seguridad',
|
||||
stopBeforeBackupHelper:
|
||||
'Al habilitarlo, se detendrá el contenedor o servicio compose antes del respaldo y se restaurará automáticamente al finalizar para garantizar la consistencia de datos.',
|
||||
commandInput: 'Introducir comando',
|
||||
commandRule: 'Por favor introduzca el comando correcto para crear el contenedor con docker run.',
|
||||
commandHelper: 'Este comando se ejecutará en el servidor para crear el contenedor. ¿Desea continuar?',
|
||||
|
||||
@@ -840,6 +840,12 @@ const message = {
|
||||
},
|
||||
container: {
|
||||
createByCommand: 'コマンドで作成',
|
||||
importContainerBackupTip: 'コンテナバックアップファイルをインポートします。.tar.gz のみ対応です',
|
||||
importComposeBackupTip: 'Compose バックアップファイルをインポートします。.tar.gz のみ対応です',
|
||||
stopContainerBeforeBackup: 'バックアップ前にコンテナを停止',
|
||||
stopComposeBeforeBackup: 'バックアップ前に Compose を停止',
|
||||
stopBeforeBackupHelper:
|
||||
'有効にすると、バックアップ前にコンテナまたは Compose サービスを停止し、完了後に自動で復元してデータ整合性を確保します。',
|
||||
commandInput: 'コマンド入力',
|
||||
commandRule: 'コンテナ作成用の正しい docker run コマンドを入力してください。',
|
||||
commandHelper: 'このコマンドはサーバー上で実行され、コンテナを作成します。続行しますか?',
|
||||
|
||||
@@ -829,6 +829,12 @@ const message = {
|
||||
},
|
||||
container: {
|
||||
createByCommand: '명령으로 생성',
|
||||
importContainerBackupTip: '컨테이너 백업 파일 가져오기, .tar.gz 형식만 지원합니다',
|
||||
importComposeBackupTip: 'Compose 백업 파일 가져오기, .tar.gz 형식만 지원합니다',
|
||||
stopContainerBeforeBackup: '백업 전에 컨테이너 중지',
|
||||
stopComposeBeforeBackup: '백업 전에 Compose 중지',
|
||||
stopBeforeBackupHelper:
|
||||
'활성화하면 백업 전에 컨테이너 또는 Compose 서비스를 중지하고, 완료 후 자동으로 복구하여 데이터 일관성을 보장합니다.',
|
||||
commandInput: '명령 입력',
|
||||
commandRule: '컨테이너 생성을 위한 올바른 docker run 명령을 입력하세요.',
|
||||
commandHelper: '이 명령은 서버에서 실행되어 컨테이너를 생성합니다. 계속하시겠습니까?',
|
||||
|
||||
@@ -848,6 +848,12 @@ const message = {
|
||||
},
|
||||
container: {
|
||||
createByCommand: 'Cipta melalui perintah',
|
||||
importContainerBackupTip: 'Import fail sandaran kontena, hanya format .tar.gz disokong',
|
||||
importComposeBackupTip: 'Import fail sandaran compose, hanya format .tar.gz disokong',
|
||||
stopContainerBeforeBackup: 'Hentikan kontena sebelum sandaran',
|
||||
stopComposeBeforeBackup: 'Hentikan compose sebelum sandaran',
|
||||
stopBeforeBackupHelper:
|
||||
'Apabila diaktifkan, kontena atau perkhidmatan compose akan dihentikan sebelum sandaran dan dipulihkan secara automatik selepas selesai untuk memastikan ketekalan data.',
|
||||
commandInput: 'Input perintah',
|
||||
commandRule: 'Sila masukkan arahan docker run yang sah untuk mencipta kontena.',
|
||||
commandHelper: 'Arahan ini akan dijalankan pada pelayan untuk mencipta kontena. Teruskan?',
|
||||
|
||||
@@ -844,6 +844,12 @@ const message = {
|
||||
},
|
||||
container: {
|
||||
createByCommand: 'Criar por comando',
|
||||
importContainerBackupTip: 'Importar arquivo de backup de contêiner, apenas .tar.gz é suportado',
|
||||
importComposeBackupTip: 'Importar arquivo de backup de compose, apenas .tar.gz é suportado',
|
||||
stopContainerBeforeBackup: 'Parar contêiner antes do backup',
|
||||
stopComposeBeforeBackup: 'Parar compose antes do backup',
|
||||
stopBeforeBackupHelper:
|
||||
'Quando habilitado, o contêiner ou serviço compose será interrompido antes do backup e restaurado automaticamente após a conclusão para garantir consistência dos dados.',
|
||||
commandInput: 'Entrada de comando',
|
||||
commandRule: 'Insira um comando docker run válido para criar o contêiner.',
|
||||
commandHelper: 'Este comando será executado no servidor para criar o contêiner. Continuar?',
|
||||
|
||||
@@ -840,6 +840,12 @@ const message = {
|
||||
},
|
||||
container: {
|
||||
createByCommand: 'Создать с помощью команды',
|
||||
importContainerBackupTip: 'Импорт файла резервной копии контейнера, поддерживается только .tar.gz',
|
||||
importComposeBackupTip: 'Импорт файла резервной копии compose, поддерживается только .tar.gz',
|
||||
stopContainerBeforeBackup: 'Остановить контейнер перед резервным копированием',
|
||||
stopComposeBeforeBackup: 'Остановить compose перед резервным копированием',
|
||||
stopBeforeBackupHelper:
|
||||
'При включении контейнер или сервис compose будет остановлен перед резервным копированием и автоматически восстановлен после завершения для обеспечения целостности данных.',
|
||||
commandInput: 'Ввод команды',
|
||||
commandRule: 'Введите корректную команду docker run для создания контейнера.',
|
||||
commandHelper: 'Эта команда будет выполнена на сервере для создания контейнера. Продолжить?',
|
||||
|
||||
@@ -845,6 +845,12 @@ const message = {
|
||||
},
|
||||
container: {
|
||||
createByCommand: 'Komutla oluştur',
|
||||
importContainerBackupTip: 'Konteyner yedek dosyasını içe aktar, yalnızca .tar.gz desteklenir',
|
||||
importComposeBackupTip: 'Compose yedek dosyasını içe aktar, yalnızca .tar.gz desteklenir',
|
||||
stopContainerBeforeBackup: 'Yedekleme öncesi konteyneri durdur',
|
||||
stopComposeBeforeBackup: 'Yedekleme öncesi compose’u durdur',
|
||||
stopBeforeBackupHelper:
|
||||
'Etkinleştirildiğinde, yedekleme öncesinde konteyner veya compose servisi durdurulur ve veri tutarlılığını sağlamak için işlem tamamlandıktan sonra otomatik olarak geri açılır.',
|
||||
commandInput: 'Komut girişi',
|
||||
commandRule: 'Lütfen doğru docker run konteyner oluşturma komutunu girin!',
|
||||
commandHelper: 'Bu komut konteyneri oluşturmak için sunucuda çalıştırılacak. Devam etmek istiyor musunuz?',
|
||||
|
||||
@@ -801,6 +801,12 @@ const message = {
|
||||
},
|
||||
container: {
|
||||
createByCommand: '指令建立',
|
||||
importContainerBackupTip: '匯入容器備份檔案,僅支援 .tar.gz 格式',
|
||||
importComposeBackupTip: '匯入編排備份檔案,僅支援 .tar.gz 格式',
|
||||
stopContainerBeforeBackup: '備份前停止容器',
|
||||
stopComposeBeforeBackup: '備份前停止編排',
|
||||
stopBeforeBackupHelper:
|
||||
'啟用後備份前將停止容器或編排服務,完成後自動恢復,以確保資料一致性',
|
||||
commandInput: '指令輸入',
|
||||
commandRule: '請輸入正確的 docker run 容器建立指令',
|
||||
commandHelper: '將在伺服器上執行該條指令以建立容器,是否繼續?',
|
||||
|
||||
@@ -800,6 +800,11 @@ const message = {
|
||||
},
|
||||
container: {
|
||||
createByCommand: '命令创建',
|
||||
importContainerBackupTip: '导入容器备份文件,仅支持 .tar.gz 格式',
|
||||
importComposeBackupTip: '导入编排备份文件,仅支持 .tar.gz 格式',
|
||||
stopContainerBeforeBackup: '备份前停止容器',
|
||||
stopComposeBeforeBackup: '备份前停止编排',
|
||||
stopBeforeBackupHelper: '启用后备份前将停止容器或编排服务,完成后自动恢复,以确保数据一致性',
|
||||
commandInput: '命令输入',
|
||||
commandRule: '请输入正确的 docker run 容器创建命令!',
|
||||
commandHelper: '将在服务器上执行该条命令以创建容器,是否继续?',
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<el-button type="primary" @click="onOpenDialog()">
|
||||
{{ $t('commons.button.create') }}
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="onImportCompose()">
|
||||
{{ $t('commons.button.import') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<template #rightToolBar>
|
||||
<TableSearch @search="search()" v-model:searchName="searchName" />
|
||||
@@ -68,11 +71,44 @@
|
||||
</el-text>
|
||||
</div>
|
||||
<div class="compose-actions">
|
||||
<el-dropdown placement="bottom">
|
||||
<Status
|
||||
v-if="row.containerCount === row.runningCount"
|
||||
status="running"
|
||||
:operate="true"
|
||||
/>
|
||||
<Status
|
||||
v-if="row.runningCount === 0"
|
||||
status="exited"
|
||||
:operate="true"
|
||||
/>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
:disabled="row.containerCount === row.runningCount"
|
||||
@click="handleComposeOperate('up', row)"
|
||||
>
|
||||
{{ $t('commons.operate.start') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
:disabled="row.runningCount === 0"
|
||||
@click="handleComposeOperate('stop', row)"
|
||||
>
|
||||
{{ $t('commons.operate.stop') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
@click="handleComposeOperate('restart', row)"
|
||||
>
|
||||
{{ $t('commons.button.restart') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button
|
||||
plain
|
||||
round
|
||||
size="small"
|
||||
class="round-btn"
|
||||
class="round-btn ml-3"
|
||||
:disabled="!row?.workdir"
|
||||
@click="openComposeFolder(row)"
|
||||
>
|
||||
@@ -83,27 +119,9 @@
|
||||
round
|
||||
size="small"
|
||||
class="round-btn"
|
||||
@click="handleComposeOperate('up', row)"
|
||||
@click="onBackupList(row)"
|
||||
>
|
||||
{{ $t('commons.operate.start') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
plain
|
||||
round
|
||||
size="small"
|
||||
class="round-btn"
|
||||
@click="handleComposeOperate('stop', row)"
|
||||
>
|
||||
{{ $t('commons.operate.stop') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
plain
|
||||
round
|
||||
size="small"
|
||||
class="round-btn"
|
||||
@click="handleComposeOperate('restart', row)"
|
||||
>
|
||||
{{ $t('commons.operate.restart') }}
|
||||
{{ $t('commons.button.backup') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
plain
|
||||
@@ -422,6 +440,8 @@
|
||||
<ContainerInspectDialog ref="containerInspectRef" />
|
||||
<TerminalDialog ref="terminalDialogRef" />
|
||||
<ContainerLogDialog ref="containerLogDialogRef" :highlightDiff="210" />
|
||||
<Backups ref="dialogBackupRef" />
|
||||
<Uploads ref="uploadRef" @close="search(true)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -435,6 +455,8 @@ import ContainerInspectDialog from '@/views/container/container/inspect/index.vu
|
||||
import TerminalDialog from '@/views/container/container/terminal/index.vue';
|
||||
import ContainerLogDialog from '@/components/log/container-drawer/index.vue';
|
||||
import DeleteDialog from '@/views/container/compose/delete/index.vue';
|
||||
import Backups from '@/components/backup/index.vue';
|
||||
import Uploads from '@/components/upload/index.vue';
|
||||
import {
|
||||
composeOperate,
|
||||
composeUpdate,
|
||||
@@ -456,6 +478,9 @@ import { computeCPU, computeSize2, computeSizeForDocker, newUUID } from '@/utils
|
||||
import { Rules } from '@/global/form-rules';
|
||||
import { loadBaseDir } from '@/api/modules/setting';
|
||||
import { ElCheckbox, ElForm } from 'element-plus';
|
||||
import { GlobalStore } from '@/store';
|
||||
|
||||
const globalStore = GlobalStore();
|
||||
|
||||
const data = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
@@ -469,6 +494,8 @@ const dialogDelRef = ref();
|
||||
const containerInspectRef = ref();
|
||||
const terminalDialogRef = ref();
|
||||
const containerLogDialogRef = ref();
|
||||
const dialogBackupRef = ref();
|
||||
const uploadRef = ref();
|
||||
|
||||
const searchName = ref('');
|
||||
const showType = ref('compose');
|
||||
@@ -701,6 +728,26 @@ const onDelete = (row: any) => {
|
||||
});
|
||||
};
|
||||
|
||||
const onBackupList = (row: Container.ComposeInfo) => {
|
||||
dialogBackupRef.value?.acceptParams({
|
||||
type: 'compose',
|
||||
name: row.name,
|
||||
detailName: '',
|
||||
status: 'running',
|
||||
node: globalStore.currentNode,
|
||||
});
|
||||
};
|
||||
|
||||
const onImportCompose = () => {
|
||||
uploadRef.value?.acceptParams({
|
||||
type: 'compose',
|
||||
name: '',
|
||||
detailName: '',
|
||||
remark: '.tar.gz',
|
||||
node: globalStore.currentNode,
|
||||
});
|
||||
};
|
||||
|
||||
const handleComposeOperate = async (operation: 'up' | 'stop' | 'restart', row: any) => {
|
||||
const mes = i18n.global.t('container.composeOperatorHelper', [
|
||||
row.name,
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
<el-button type="primary" @click="onContainerOperate('')">
|
||||
{{ $t('commons.button.create') }}
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="onImportCreate()">
|
||||
{{ $t('commons.button.import') }}
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="onClean()">
|
||||
{{ $t('container.containerPrune') }}
|
||||
</el-button>
|
||||
@@ -377,6 +380,8 @@
|
||||
<TerminalDialog ref="dialogTerminalRef" />
|
||||
|
||||
<PortJumpDialog ref="dialogPortJumpRef" />
|
||||
<Backups ref="dialogBackupRef" />
|
||||
<Uploads ref="uploadRef" @close="search" />
|
||||
<TaskLog ref="taskLogRef" width="70%" @close="search" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -391,6 +396,8 @@ import TerminalDialog from '@/views/container/container/terminal/index.vue';
|
||||
import ContainerInspectDialog from '@/views/container/container/inspect/index.vue';
|
||||
import PortJumpDialog from '@/components/port-jump/index.vue';
|
||||
import TaskLog from '@/components/log/task/index.vue';
|
||||
import Backups from '@/components/backup/index.vue';
|
||||
import Uploads from '@/components/upload/index.vue';
|
||||
import DockerStatus from '@/views/container/docker-status/index.vue';
|
||||
import ContainerLogDialog from '@/components/log/container-drawer/index.vue';
|
||||
import Status from '@/components/status/index.vue';
|
||||
@@ -435,6 +442,7 @@ const searchName = ref();
|
||||
const dialogUpgradeRef = ref();
|
||||
const dialogCommitRef = ref();
|
||||
const dialogPortJumpRef = ref();
|
||||
const dialogBackupRef = ref();
|
||||
const opRef = ref();
|
||||
const includeAppStore = ref(true);
|
||||
const columns = ref([]);
|
||||
@@ -637,6 +645,26 @@ const onContainerOperate = async (container: string) => {
|
||||
routerToNameWithQuery('ContainerCreate', { name: container });
|
||||
};
|
||||
|
||||
const onBackup = (row: Container.ContainerInfo) => {
|
||||
dialogBackupRef.value!.acceptParams({
|
||||
type: 'container',
|
||||
name: row.name,
|
||||
detailName: '',
|
||||
status: row.state,
|
||||
});
|
||||
};
|
||||
|
||||
const uploadRef = ref();
|
||||
const onImportCreate = () => {
|
||||
uploadRef.value!.acceptParams({
|
||||
type: 'container',
|
||||
name: '',
|
||||
detailName: '',
|
||||
remark: '.tar.gz',
|
||||
node: globalStore.currentNode,
|
||||
});
|
||||
};
|
||||
|
||||
const dialogMonitorRef = ref();
|
||||
const onMonitor = (row: any) => {
|
||||
dialogMonitorRef.value!.acceptParams({ containerID: row.containerID, container: row.name });
|
||||
@@ -766,6 +794,12 @@ const buttons = [
|
||||
dialogUpgradeRef.value!.acceptParams({ container: row.name, image: row.imageName, fromApp: row.isFromApp });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: i18n.global.t('commons.button.backup'),
|
||||
click: (row: Container.ContainerInfo) => {
|
||||
onBackup(row);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: i18n.global.t('menu.monitor'),
|
||||
disabled: (row: Container.ContainerInfo) => {
|
||||
|
||||
Reference in New Issue
Block a user