feat: complete backup and recovery for containers and compose (#12153)

Refs #9656
This commit is contained in:
ssongliu
2026-03-12 09:03:34 +00:00
committed by GitHub
parent eedbc2e95c
commit 21826bcc1a
33 changed files with 2173 additions and 39 deletions
+30
View File
@@ -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)
}
+3 -2
View File
@@ -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"`
+5
View 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 {
+707
View File
@@ -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
}
+891
View File
@@ -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
}
+2 -1
View File
@@ -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 {
+30
View File
@@ -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 }}'
+30
View File
@@ -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.'
+30
View File
@@ -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: 'イメージはすでに存在します!イメージ名を変更してください。'
+30
View File
@@ -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: '이미지가 이미 존재합니다 이미지 이름을 수정하세요.'
+30
View File
@@ -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.'
+30
View File
@@ -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.'
+30
View File
@@ -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: 'Изображение уже существует Пожалуйста, измените имя изображения.'
+30
View File
@@ -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 composeu durdur'
ComposeBackupFiles: 'Compose dosyalarını yedekle'
ComposeBackupContainers: 'Compose konteynerlerini yedekle'
ComposeBackupMeta: 'Compose yedek meta verisini yaz'
ComposeBackupStart: 'Yedeklemeden sonra composeu 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.'
+30
View File
@@ -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: '映像已存在!請修改映像名稱。'
+30
View File
@@ -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 }} 文件不存在!请检查源文件完整性!"
+1
View File
@@ -93,6 +93,7 @@ export namespace Backup {
detailName: string;
secret: string;
taskID: string;
stopBefore?: boolean;
}
export interface Recover {
downloadAccountID: number;
+6 -4
View File
@@ -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}` : '';
+3 -2
View File
@@ -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`, {});
+18 -1
View File
@@ -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;
};
+46 -8
View File
@@ -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 = () => {
+6
View File
@@ -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?',
+6
View File
@@ -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?',
+6
View File
@@ -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: 'このコマンドはサーバー上で実行されコンテナを作成します続行しますか',
+6
View File
@@ -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: ' 명령은 서버에서 실행되어 컨테이너를 생성합니다. 계속하시겠습니까?',
+6
View File
@@ -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?',
+6
View File
@@ -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?',
+6
View File
@@ -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: 'Эта команда будет выполнена на сервере для создания контейнера. Продолжить?',
+6
View File
@@ -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 composeu 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?',
+6
View File
@@ -801,6 +801,12 @@ const message = {
},
container: {
createByCommand: '指令建立',
importContainerBackupTip: '匯入容器備份檔案僅支援 .tar.gz 格式',
importComposeBackupTip: '匯入編排備份檔案僅支援 .tar.gz 格式',
stopContainerBeforeBackup: '備份前停止容器',
stopComposeBeforeBackup: '備份前停止編排',
stopBeforeBackupHelper:
'啟用後備份前將停止容器或編排服務完成後自動恢復以確保資料一致性',
commandInput: '指令輸入',
commandRule: '請輸入正確的 docker run 容器建立指令',
commandHelper: '將在伺服器上執行該條指令以建立容器是否繼續',
+5
View File
@@ -800,6 +800,11 @@ const message = {
},
container: {
createByCommand: '命令创建',
importContainerBackupTip: '导入容器备份文件仅支持 .tar.gz 格式',
importComposeBackupTip: '导入编排备份文件仅支持 .tar.gz 格式',
stopContainerBeforeBackup: '备份前停止容器',
stopComposeBeforeBackup: '备份前停止编排',
stopBeforeBackupHelper: '启用后备份前将停止容器或编排服务完成后自动恢复以确保数据一致性',
commandInput: '命令输入',
commandRule: '请输入正确的 docker run 容器创建命令',
commandHelper: '将在服务器上执行该条命令以创建容器是否继续',
+68 -21
View File
@@ -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) => {