diff --git a/agent/app/api/v2/backup.go b/agent/app/api/v2/backup.go index d998f26245c3..855f2e62fdcf 100644 --- a/agent/app/api/v2/backup.go +++ b/agent/app/api/v2/backup.go @@ -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) } diff --git a/agent/app/dto/backup.go b/agent/app/dto/backup.go index c08c12db66bd..11158f53c103 100644 --- a/agent/app/dto/backup.go +++ b/agent/app/dto/backup.go @@ -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"` diff --git a/agent/app/service/backup.go b/agent/app/service/backup.go index ee9ef35d3994..b75fbaca5e78 100644 --- a/agent/app/service/backup.go +++ b/agent/app/service/backup.go @@ -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 { diff --git a/agent/app/service/backup_compose.go b/agent/app/service/backup_compose.go new file mode 100644 index 000000000000..3599a77ba5ae --- /dev/null +++ b/agent/app/service/backup_compose.go @@ -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 +} diff --git a/agent/app/service/backup_container.go b/agent/app/service/backup_container.go new file mode 100644 index 000000000000..eebdf121e9a5 --- /dev/null +++ b/agent/app/service/backup_container.go @@ -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 +} diff --git a/agent/app/task/task.go b/agent/app/task/task.go index 4222acd38563..5d9a686c2eb8 100644 --- a/agent/app/task/task.go +++ b/agent/app/task/task.go @@ -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 { diff --git a/agent/i18n/lang/en.yaml b/agent/i18n/lang/en.yaml index 9cf17cda9e9c..b46a1e821c15 100644 --- a/agent/i18n/lang/en.yaml +++ b/agent/i18n/lang/en.yaml @@ -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 }}' diff --git a/agent/i18n/lang/es-ES.yaml b/agent/i18n/lang/es-ES.yaml index 0c4eb80f6c29..66cd70b7a485 100644 --- a/agent/i18n/lang/es-ES.yaml +++ b/agent/i18n/lang/es-ES.yaml @@ -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.' diff --git a/agent/i18n/lang/ja.yaml b/agent/i18n/lang/ja.yaml index c32f3ccac0b5..24631f86facb 100644 --- a/agent/i18n/lang/ja.yaml +++ b/agent/i18n/lang/ja.yaml @@ -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: 'イメージはすでに存在します!イメージ名を変更してください。' diff --git a/agent/i18n/lang/ko.yaml b/agent/i18n/lang/ko.yaml index e317a57ad2aa..1fdf59309228 100644 --- a/agent/i18n/lang/ko.yaml +++ b/agent/i18n/lang/ko.yaml @@ -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: '이미지가 이미 존재합니다 이미지 이름을 수정하세요.' diff --git a/agent/i18n/lang/ms.yaml b/agent/i18n/lang/ms.yaml index 50219a91815a..c600744793e8 100644 --- a/agent/i18n/lang/ms.yaml +++ b/agent/i18n/lang/ms.yaml @@ -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.' diff --git a/agent/i18n/lang/pt-BR.yaml b/agent/i18n/lang/pt-BR.yaml index 6eed119c562b..9233e877f154 100644 --- a/agent/i18n/lang/pt-BR.yaml +++ b/agent/i18n/lang/pt-BR.yaml @@ -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.' diff --git a/agent/i18n/lang/ru.yaml b/agent/i18n/lang/ru.yaml index 87078ced9b55..4f647e6ad7e9 100644 --- a/agent/i18n/lang/ru.yaml +++ b/agent/i18n/lang/ru.yaml @@ -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: 'Изображение уже существует Пожалуйста, измените имя изображения.' diff --git a/agent/i18n/lang/tr.yaml b/agent/i18n/lang/tr.yaml index 471123c5378f..897c5fbec05e 100644 --- a/agent/i18n/lang/tr.yaml +++ b/agent/i18n/lang/tr.yaml @@ -209,6 +209,36 @@ BuildCache: 'Derleme önbelleği' Volume: 'Depolama hacmi' Network: 'Ağ' PruneStart: 'Temizlik devam ediyor, lütfen bekleyin...' +ContainerBackupPrepare: 'Konteyner yedekleme çalışma alanını hazırla' +ContainerBackupStop: 'Yedeklemeden önce konteyneri durdur' +ContainerBackupInspect: 'Konteyner inspect ve ağ bilgisini yedekle' +ContainerBackupMounts: 'Konteyner mount verilerini yedekle' +ContainerBackupMeta: 'Konteyner yedekleme meta verisini yaz' +ContainerBackupStart: 'Yedeklemeden sonra konteyneri başlat' +ContainerRecoverPrepare: 'Konteyner geri yükleme çalışma alanını hazırla' +ContainerRecoverExtract: 'Konteyner yedek arşivini çıkar' +ContainerRecoverParse: 'Konteyner yedek meta verisini yükle' +ContainerRecoverCreate: 'Konteyneri yeniden oluştur' +ContainerRecoverMounts: 'Konteyner mount verilerini geri yükle' +ContainerRecoverStart: 'Geri yüklenen konteyneri başlat' +ContainerRecoverCleanup: 'Konteyner geri yükleme çalışma alanını temizle' +ComposeBackupPrepare: 'Compose yedekleme çalışma alanını hazırla' +ComposeBackupStop: 'Yedeklemeden önce compose’u durdur' +ComposeBackupFiles: 'Compose dosyalarını yedekle' +ComposeBackupContainers: 'Compose konteynerlerini yedekle' +ComposeBackupMeta: 'Compose yedek meta verisini yaz' +ComposeBackupStart: 'Yedeklemeden sonra compose’u başlat' +ComposeBackupCleanup: 'Compose yedekleme çalışma alanını temizle' +ComposeRecoverPrepare: 'Compose geri yükleme çalışma alanını hazırla' +ComposeRecoverExtract: 'Compose yedek arşivini çıkar' +ComposeRecoverMeta: 'Compose yedek meta verisini yükle' +ComposeRecoverMetaLogName: 'Compose geri yükleme meta: ad={{ .name }}' +ComposeRecoverMetaLogPath: 'Compose geri yükleme meta: yedekYolu={{ .backupPath }} hedefYol={{ .targetDir }}' +ComposeRecoverMetaLogCount: 'Compose geri yükleme meta: dosya={{ .files }} konteyner={{ .containers }}' +ComposeRecoverFiles: 'Compose dosyalarını geri yükle' +ComposeRecoverContainers: 'Compose konteynerlerini geri yükle' +ComposeRecoverRecord: 'Compose kaydını sakla' +ComposeRecoverCleanup: 'Compose geri yükleme çalışma alanını temizle' ErrFileNotExist: '{{ .detail }} dosyası mevcut değil Lütfen kaynak dosyanın bütünlüğünü kontrol edin' ErrImageBuildErr: 'Image yapı başarısız' ErrImageExist: 'Görüntü zaten var Lütfen görüntü adını değiştirin.' diff --git a/agent/i18n/lang/zh-Hant.yaml b/agent/i18n/lang/zh-Hant.yaml index b8923715d76f..299db6ac813b 100644 --- a/agent/i18n/lang/zh-Hant.yaml +++ b/agent/i18n/lang/zh-Hant.yaml @@ -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: '映像已存在!請修改映像名稱。' diff --git a/agent/i18n/lang/zh.yaml b/agent/i18n/lang/zh.yaml index b596f250552e..0a36ea853987 100644 --- a/agent/i18n/lang/zh.yaml +++ b/agent/i18n/lang/zh.yaml @@ -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 }} 文件不存在!请检查源文件完整性!" diff --git a/frontend/src/api/interface/backup.ts b/frontend/src/api/interface/backup.ts index 2532e3e4439f..e9b74dd85777 100644 --- a/frontend/src/api/interface/backup.ts +++ b/frontend/src/api/interface/backup.ts @@ -93,6 +93,7 @@ export namespace Backup { detailName: string; secret: string; taskID: string; + stopBefore?: boolean; } export interface Recover { downloadAccountID: number; diff --git a/frontend/src/api/modules/backup.ts b/frontend/src/api/modules/backup.ts index db8aa5b7b19d..6a306be49829 100644 --- a/frontend/src/api/modules/backup.ts +++ b/frontend/src/api/modules/backup.ts @@ -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}` : ''; diff --git a/frontend/src/api/modules/setting.ts b/frontend/src/api/modules/setting.ts index 3121a9696bef..a62ed3059940 100644 --- a/frontend/src/api/modules/setting.ts +++ b/frontend/src/api/modules/setting.ts @@ -60,8 +60,9 @@ export const listAppNodes = () => { }; // agent -export const loadBaseDir = () => { - return http.get(`/settings/basedir`); +export const loadBaseDir = (node?: string) => { + const query = node ? `?operateNode=${node}` : ''; + return http.get(`/settings/basedir${query}`); }; export const loadDaemonJsonPath = () => { return http.get(`/settings/daemonjson`, {}); diff --git a/frontend/src/components/backup/index.vue b/frontend/src/components/backup/index.vue index 3dcc7c5f7396..305e15dd0c95 100644 --- a/frontend/src/components/backup/index.vue +++ b/frontend/src/components/backup/index.vue @@ -27,7 +27,11 @@ style="width: 100%" > @@ -86,7 +90,7 @@ @@ -125,7 +129,7 @@ - + @@ -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 => { 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 => { 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 = () => { diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 846bab61ad44..a76752644760 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -837,6 +837,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?', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index fa25ae5bd056..afe74e3b8275 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -849,6 +849,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?', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 10b97242074c..64acf9623c88 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -838,6 +838,12 @@ const message = { }, container: { createByCommand: 'コマンドで作成', + importContainerBackupTip: 'コンテナバックアップファイルをインポートします。.tar.gz のみ対応です', + importComposeBackupTip: 'Compose バックアップファイルをインポートします。.tar.gz のみ対応です', + stopContainerBeforeBackup: 'バックアップ前にコンテナを停止', + stopComposeBeforeBackup: 'バックアップ前に Compose を停止', + stopBeforeBackupHelper: + '有効にすると、バックアップ前にコンテナまたは Compose サービスを停止し、完了後に自動で復元してデータ整合性を確保します。', commandInput: 'コマンド入力', commandRule: 'コンテナ作成用の正しい docker run コマンドを入力してください。', commandHelper: 'このコマンドはサーバー上で実行され、コンテナを作成します。続行しますか?', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 71dd3fbb139c..19614b1229c6 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -827,6 +827,12 @@ const message = { }, container: { createByCommand: '명령으로 생성', + importContainerBackupTip: '컨테이너 백업 파일 가져오기, .tar.gz 형식만 지원합니다', + importComposeBackupTip: 'Compose 백업 파일 가져오기, .tar.gz 형식만 지원합니다', + stopContainerBeforeBackup: '백업 전에 컨테이너 중지', + stopComposeBeforeBackup: '백업 전에 Compose 중지', + stopBeforeBackupHelper: + '활성화하면 백업 전에 컨테이너 또는 Compose 서비스를 중지하고, 완료 후 자동으로 복구하여 데이터 일관성을 보장합니다.', commandInput: '명령 입력', commandRule: '컨테이너 생성을 위한 올바른 docker run 명령을 입력하세요.', commandHelper: '이 명령은 서버에서 실행되어 컨테이너를 생성합니다. 계속하시겠습니까?', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 1c9728f75327..60fbaea89d8f 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -846,6 +846,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?', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index f921f7de4119..8747c62c3c84 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -842,6 +842,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?', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index 8d183c08c2e3..7e8e6f83cf12 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -838,6 +838,12 @@ const message = { }, container: { createByCommand: 'Создать с помощью команды', + importContainerBackupTip: 'Импорт файла резервной копии контейнера, поддерживается только .tar.gz', + importComposeBackupTip: 'Импорт файла резервной копии compose, поддерживается только .tar.gz', + stopContainerBeforeBackup: 'Остановить контейнер перед резервным копированием', + stopComposeBeforeBackup: 'Остановить compose перед резервным копированием', + stopBeforeBackupHelper: + 'При включении контейнер или сервис compose будет остановлен перед резервным копированием и автоматически восстановлен после завершения для обеспечения целостности данных.', commandInput: 'Ввод команды', commandRule: 'Введите корректную команду docker run для создания контейнера.', commandHelper: 'Эта команда будет выполнена на сервере для создания контейнера. Продолжить?', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index eaf4342a5a49..e1c91ea12c3d 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -843,6 +843,12 @@ const message = { }, container: { createByCommand: 'Komutla oluştur', + importContainerBackupTip: 'Konteyner yedek dosyasını içe aktar, yalnızca .tar.gz desteklenir', + importComposeBackupTip: 'Compose yedek dosyasını içe aktar, yalnızca .tar.gz desteklenir', + stopContainerBeforeBackup: 'Yedekleme öncesi konteyneri durdur', + stopComposeBeforeBackup: 'Yedekleme öncesi compose’u durdur', + stopBeforeBackupHelper: + 'Etkinleştirildiğinde, yedekleme öncesinde konteyner veya compose servisi durdurulur ve veri tutarlılığını sağlamak için işlem tamamlandıktan sonra otomatik olarak geri açılır.', commandInput: 'Komut girişi', commandRule: 'Lütfen doğru docker run konteyner oluşturma komutunu girin!', commandHelper: 'Bu komut konteyneri oluşturmak için sunucuda çalıştırılacak. Devam etmek istiyor musunuz?', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index e503879a94f1..e383474a777b 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -799,6 +799,12 @@ const message = { }, container: { createByCommand: '指令建立', + importContainerBackupTip: '匯入容器備份檔案,僅支援 .tar.gz 格式', + importComposeBackupTip: '匯入編排備份檔案,僅支援 .tar.gz 格式', + stopContainerBeforeBackup: '備份前停止容器', + stopComposeBeforeBackup: '備份前停止編排', + stopBeforeBackupHelper: + '啟用後備份前將停止容器或編排服務,完成後自動恢復,以確保資料一致性', commandInput: '指令輸入', commandRule: '請輸入正確的 docker run 容器建立指令', commandHelper: '將在伺服器上執行該條指令以建立容器,是否繼續?', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 9108a288cd77..83fc9978a7fb 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -798,6 +798,11 @@ const message = { }, container: { createByCommand: '命令创建', + importContainerBackupTip: '导入容器备份文件,仅支持 .tar.gz 格式', + importComposeBackupTip: '导入编排备份文件,仅支持 .tar.gz 格式', + stopContainerBeforeBackup: '备份前停止容器', + stopComposeBeforeBackup: '备份前停止编排', + stopBeforeBackupHelper: '启用后备份前将停止容器或编排服务,完成后自动恢复,以确保数据一致性', commandInput: '命令输入', commandRule: '请输入正确的 docker run 容器创建命令!', commandHelper: '将在服务器上执行该条命令以创建容器,是否继续?', diff --git a/frontend/src/views/container/compose/index.vue b/frontend/src/views/container/compose/index.vue index 06372a6e6e4e..3a8f8ae17873 100644 --- a/frontend/src/views/container/compose/index.vue +++ b/frontend/src/views/container/compose/index.vue @@ -12,6 +12,9 @@ {{ $t('commons.button.create') }} + + {{ $t('commons.button.import') }} + @@ -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([]); 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, diff --git a/frontend/src/views/container/container/index.vue b/frontend/src/views/container/container/index.vue index 9ac411950ac4..320c828c91fa 100644 --- a/frontend/src/views/container/container/index.vue +++ b/frontend/src/views/container/container/index.vue @@ -25,6 +25,9 @@ {{ $t('commons.button.create') }} + + {{ $t('commons.button.import') }} + {{ $t('container.containerPrune') }} @@ -377,6 +380,8 @@ + + @@ -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) => {