diff --git a/agent/app/service/backup_compose.go b/agent/app/service/backup_compose.go index 62d4f1398..a98e232c1 100644 --- a/agent/app/service/backup_compose.go +++ b/agent/app/service/backup_compose.go @@ -371,43 +371,39 @@ func loadComposePathAndFiles(composeName string, dockerClient *client.Client) (s } config := targetContainer.Labels[composeConfigLabel] workdir := targetContainer.Labels[composeWorkdirLabel] - if len(config) != 0 && len(workdir) != 0 && strings.Contains(config, workdir) { - composePath = config - } else { - composePath = workdir + files, err := resolveComposeFiles(workdir, config) + if err != nil { + return "", nil, err } + composePath = strings.Join(files, ",") } - composeFiles := normalizeComposeFiles(composePath) - if len(composeFiles) == 0 { - return "", nil, fmt.Errorf("compose file not found for %s", composeName) + composeFiles, err := normalizeComposeFiles(composePath) + if err != nil { + return "", nil, err } - return composePath, composeFiles, nil + return strings.Join(composeFiles, ","), 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) +func normalizeComposeFiles(composePath string) ([]string, error) { + files, err := resolveComposeFiles("", composePath) + if err != nil { + return nil, err } - sort.Strings(result) - return result + for i, file := range files { + info, err := os.Stat(file) + if err == nil && info.IsDir() { + file = filepath.Join(file, "docker-compose.yml") + info, err = os.Stat(file) + files[i] = file + } + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("compose path %s is not a regular file", file) + } + } + return files, nil } func (c *composeBackupContext) close() { @@ -621,20 +617,46 @@ func safeJoinWithinBase(baseDir, name string) (string, error) { } func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error { - if recoverCtx.targetDir != "" { - _ = os.RemoveAll(recoverCtx.targetDir) + composeFiles := recoverCtx.meta.Files + if strings.TrimSpace(recoverCtx.meta.ComposePath) != "" { + filesByPath := make(map[string]composeBackupFile, len(composeFiles)) + for _, item := range composeFiles { + filesByPath[filepath.Clean(item.OriginalPath)] = item + } + orderedFiles := make([]composeBackupFile, 0, len(composeFiles)) + for _, file := range strings.Split(recoverCtx.meta.ComposePath, ",") { + file = strings.TrimSpace(file) + if file == "" { + continue + } + file = filepath.Clean(file) + item, ok := filesByPath[file] + if !ok { + if len(composeFiles) != 1 || file != filepath.Dir(composeFiles[0].OriginalPath) { + return fmt.Errorf("compose file %s is missing from backup metadata", file) + } + item = composeFiles[0] + } + orderedFiles = append(orderedFiles, item) + } + composeFiles = orderedFiles } - if err := os.MkdirAll(recoverCtx.targetDir, os.ModePerm); err != nil { - return err + if len(composeFiles) == 0 { + return fmt.Errorf("compose files are missing from backup metadata") } - restored := make([]string, 0, len(recoverCtx.meta.Files)) - for _, item := range recoverCtx.meta.Files { + restored := make([]string, 0, len(composeFiles)) + sourceByTarget := make(map[string]string, len(composeFiles)) + for _, item := range composeFiles { 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 + info, err := os.Stat(backupPath) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("compose backup path %s is not a regular file", backupPath) } targetName := item.FileName if item.RelativePath != "" { @@ -650,6 +672,29 @@ func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error { if err != nil { return fmt.Errorf("invalid compose target path %q, err: %v", targetName, err) } + sourcePath := item.OriginalPath + if sourcePath == "" { + sourcePath = backupPath + } + sourcePath = filepath.Clean(sourcePath) + if previous, ok := sourceByTarget[targetPath]; ok && previous != sourcePath { + return fmt.Errorf("compose files %q and %q resolve to the same restore path %q", previous, sourcePath, targetPath) + } + sourceByTarget[targetPath] = sourcePath + restored = append(restored, targetPath) + } + if recoverCtx.targetDir != "" { + _ = os.RemoveAll(recoverCtx.targetDir) + } + if err := os.MkdirAll(recoverCtx.targetDir, os.ModePerm); err != nil { + return err + } + for i, item := range composeFiles { + backupPath, err := safeJoinWithinBase(recoverCtx.tmpDir, item.BackupPath) + if err != nil { + return fmt.Errorf("invalid compose backup path %q, err: %v", item.BackupPath, err) + } + targetPath := restored[i] if err := os.MkdirAll(path.Dir(targetPath), os.ModePerm); err != nil { return err } @@ -660,7 +705,6 @@ func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error { 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) { @@ -672,14 +716,6 @@ func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error { 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 } diff --git a/agent/app/service/container.go b/agent/app/service/container.go index 418825f69..0e63fad33 100644 --- a/agent/app/service/container.go +++ b/agent/app/service/container.go @@ -326,40 +326,47 @@ func (u *ContainerService) Inspect(req dto.InspectReq) (string, error) { case "container": inspectInfo, err = client.ContainerInspect(context.Background(), req.ID) case "compose": - filePath := "" - cli, err := docker.NewDockerClient() - if err != nil { - return "", err - } - defer cli.Close() + config, workdir := "", "" options := container.ListOptions{All: true} options.Filters = filters.NewArgs() options.Filters.Add("label", fmt.Sprintf("%s=%s", composeProjectLabel, req.ID)) - containers, err := cli.ContainerList(context.Background(), options) + containers, err := client.ContainerList(context.Background(), options) if err != nil { return "", err } - for _, container := range containers { - config := container.Labels[composeConfigLabel] - if len(req.Detail) != 0 && strings.Contains(config, req.Detail) { - config = req.Detail - } - workdir := container.Labels[composeWorkdirLabel] - if len(config) != 0 && len(workdir) != 0 && strings.Contains(config, workdir) { - filePath = config - break - } else { - filePath = workdir - break - } - } if len(containers) == 0 { - composeItem, _ := composeRepo.GetRecord(repo.WithByName(req.ID)) - filePath = composeItem.Path + composeItem, err := composeRepo.GetRecord(repo.WithByName(req.ID)) + if err != nil { + return "", err + } + config = composeItem.Path + workdir = filepath.Dir(primaryComposePath(config)) + } else { + config = containers[0].Labels[composeConfigLabel] + workdir = containers[0].Labels[composeWorkdirLabel] } - if _, err := os.Stat(filePath); err != nil { + composeFiles, err := resolveComposeFiles(workdir, config) + if err != nil { return "", err } + filePath := composeFiles[0] + if strings.TrimSpace(req.Detail) != "" { + selectedFiles, err := resolveComposeFiles(workdir, req.Detail) + if err != nil { + return "", err + } + if len(selectedFiles) != 1 || !slices.Contains(composeFiles, selectedFiles[0]) { + return "", fmt.Errorf("compose file %s does not belong to project %s", req.Detail, req.ID) + } + filePath = selectedFiles[0] + } + info, err := os.Stat(filePath) + if err != nil { + return "", err + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("compose path %s is not a regular file", filePath) + } content, err := os.ReadFile(filePath) if err != nil { return "", err diff --git a/agent/app/service/container_compose.go b/agent/app/service/container_compose.go index 641bdaf33..25223542b 100644 --- a/agent/app/service/container_compose.go +++ b/agent/app/service/container_compose.go @@ -71,10 +71,9 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface ContainerCount: 0, CreatedAt: localItem.CreatedAt.Format(constant.DateTimeLayout), ConfigFile: localItem.Path, - Workdir: strings.TrimSuffix(localItem.Path, "/docker-compose.yml"), + Workdir: filepath.Dir(primaryComposePath(localItem.Path)), } composeItemLocal.CreatedBy = "1Panel" - composeItemLocal.Path = localItem.Path composeLocalMap[localItem.Name] = composeItemLocal } @@ -112,11 +111,6 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface if ok { composeItem.CreatedBy = createdBy } - if len(config) != 0 && len(workdir) != 0 && strings.Contains(config, workdir) { - composeItem.Path = config - } else { - composeItem.Path = workdir - } for i := 0; i < len(composeCreatedByLocal); i++ { if composeCreatedByLocal[i].Name == name { composeItem.CreatedBy = "1Panel" @@ -147,7 +141,9 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface for key, value := range mergedMap { value.Name = key - value.ComposeFileExists = composeFileExists(value.Workdir, value.ConfigFile) + composeFiles, err := resolveComposeFiles(value.Workdir, value.ConfigFile) + value.Path = strings.Join(composeFiles, ",") + value.ComposeFileExists = err == nil && composeFileExists(composeFiles) value.IsPinned = pinnedByName[key] records = append(records, value) } @@ -192,29 +188,6 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface return int64(total), listItem, nil } -func composeFileExists(workdir, configFile string) bool { - workdir = strings.TrimSpace(workdir) - configFile = strings.TrimSpace(configFile) - if configFile == "" { - return false - } - for _, item := range strings.Split(configFile, ",") { - file := strings.TrimSpace(item) - if file == "" { - continue - } - if !filepath.IsAbs(file) && workdir != "" { - file = filepath.Join(workdir, file) - } - file = filepath.Clean(file) - info, err := os.Stat(file) - if err == nil && !info.IsDir() { - return true - } - } - return false -} - func (u *ContainerService) TestCompose(req dto.ComposeCreate) (bool, error) { if err := validateComposeCreateName(req); err != nil { return false, err @@ -522,10 +495,6 @@ func isComposeProjectNameEmptyError(err error) bool { } func (u *ContainerService) ComposeOperation(req dto.ComposeOperation) error { - if len(req.Path) == 0 && req.Operation == "delete" { - _ = composeRepo.DeleteRecord(repo.WithByName(req.Name)) - return nil - } if cmd.CheckIllegal(req.Path, req.Operation) { return buserr.New("ErrCmdIllegal") } @@ -535,13 +504,19 @@ func (u *ContainerService) ComposeOperation(req dto.ComposeOperation) error { } if req.WithFile { for _, item := range strings.Split(req.Path, ",") { - if len(item) != 0 { - _ = os.RemoveAll(path.Dir(item)) + item = strings.TrimSpace(item) + if !filepath.IsAbs(item) || filepath.Dir(item) == "/" { + continue + } + info, err := os.Stat(item) + if err == nil && info.Mode().IsRegular() { + if err := os.RemoveAll(filepath.Dir(item)); err != nil && !req.Force { + return err + } } } } - _ = composeRepo.DeleteRecord(repo.WithByName(req.Name)) - return nil + return composeRepo.DeleteRecord(repo.WithByName(req.Name)) } if req.Operation == "up" { if stdout, err := compose.Up(req.Path, req.Name); err != nil { @@ -584,13 +559,13 @@ func (u *ContainerService) ComposeUpdate(req dto.ComposeUpdate) error { write.Flush() global.LOG.Infof("docker-compose.yml %s has been replaced, now start to docker-compose restart", req.DetailPath) - if err := newComposeEnv(req.DetailPath, req.Env); err != nil { + if err := newComposeEnv(primaryComposePath(req.Path), req.Env); err != nil { return err } if err := compose.UpWithTask(req.Path, t, req.ForcePull, req.Name); err != nil { global.LOG.Errorf("update failed when handle compose up, err: %s, now try to recreate the old compose file", err) - if err := recreateCompose(string(oldFile), req.Path, req.Name); err != nil { + if err := recreateCompose(string(oldFile), req.Path, req.DetailPath, req.Name); err != nil { return fmt.Errorf("update failed and recreate old compose file also failed, err: %v", err) } return fmt.Errorf("update failed when handle compose up, err: %s", err) @@ -702,9 +677,18 @@ func (u *ContainerService) loadPath(req *dto.ComposeCreate) error { } func removeContainerForCompose(composeName, composePath string) error { - if _, err := os.Stat(composePath); err == nil { - if stdout, err := compose.Operate(composePath, "down", composeName); err != nil { - return errors.New(stdout) + composeFiles, err := resolveComposeFiles("", composePath) + filesAvailable := err == nil + for _, file := range composeFiles { + info, err := os.Stat(file) + if err != nil || !info.Mode().IsRegular() { + filesAvailable = false + break + } + } + if filesAvailable { + if stdout, err := compose.Operate(strings.Join(composeFiles, ","), "down", composeName); err != nil { + return fmt.Errorf("docker-compose down failed, std: %s, err: %w", stdout, err) } return nil } @@ -722,14 +706,17 @@ func removeContainerForCompose(composeName, composePath string) error { if err != nil { return err } + var removeErrors []error for _, c := range containers { - _ = client.ContainerRemove(ctx, c.ID, container.RemoveOptions{RemoveVolumes: true, Force: true}) + if err := client.ContainerRemove(ctx, c.ID, container.RemoveOptions{RemoveVolumes: true, Force: true}); err != nil { + removeErrors = append(removeErrors, fmt.Errorf("remove container %s failed: %w", c.ID, err)) + } } - return nil + return errors.Join(removeErrors...) } -func recreateCompose(content, path, projectName string) error { - file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0640) +func recreateCompose(content, composePath, detailPath, projectName string) error { + file, err := os.OpenFile(detailPath, os.O_WRONLY|os.O_TRUNC, 0640) if err != nil { return err } @@ -738,7 +725,7 @@ func recreateCompose(content, path, projectName string) error { _, _ = write.WriteString(content) write.Flush() - if stdout, err := compose.Up(path, projectName); err != nil { + if stdout, err := compose.Up(composePath, projectName); err != nil { return errors.New(string(stdout)) } return nil @@ -746,6 +733,9 @@ func recreateCompose(content, path, projectName string) error { func loadEnv(list []dto.ComposeInfo) []dto.ComposeInfo { for i := 0; i < len(list); i++ { + if list[i].Path == "" { + continue + } tmpPath := list[i].Path if strings.Contains(list[i].Path, ",") { tmpPath = strings.Split(list[i].Path, ",")[0] @@ -774,3 +764,35 @@ func newComposeEnv(pathItem string, env string) error { } return nil } + +func resolveComposeFiles(workdir, configFile string) ([]string, error) { + workdir = strings.TrimSpace(workdir) + var files []string + for _, item := range strings.Split(configFile, ",") { + file := strings.TrimSpace(item) + if file == "" { + continue + } + if !filepath.IsAbs(file) { + if !filepath.IsAbs(workdir) { + return nil, fmt.Errorf("cannot resolve compose file %s without an absolute working directory", file) + } + file = filepath.Join(workdir, file) + } + files = append(files, filepath.Clean(file)) + } + if len(files) == 0 { + return nil, fmt.Errorf("compose config file is not specified") + } + return files, nil +} + +func composeFileExists(files []string) bool { + for _, file := range files { + info, err := os.Stat(file) + if err == nil && info.Mode().IsRegular() { + return true + } + } + return false +} diff --git a/frontend/src/views/container/compose/index.vue b/frontend/src/views/container/compose/index.vue index 26ce3225c..b505bf09a 100644 --- a/frontend/src/views/container/compose/index.vue +++ b/frontend/src/views/container/compose/index.vue @@ -350,6 +350,7 @@ v-if="showType !== 'log' && !(showType === 'env' && isAppStoreCompose)" v-permission type="primary" + :disabled="detailLoading || !composeContent" @click="onSubmitEdit" > {{ $t('commons.button.save') }} @@ -741,18 +742,24 @@ const loadDetail = async (row: Container.ComposeInfo, withRefresh: boolean) => { }; const inspectCompose = async (name: string, detailPath: string) => { + composeContent.value = ''; if (!currentCompose.value?.composeFileExists) { - composeContent.value = ''; loadContainerStats(); detailLoading.value = false; return; } + detailLoading.value = true; await inspect({ id: name, type: 'compose', detail: detailPath }) .then((res) => { + if (currentCompose.value?.name !== name || currentYamlPath.value !== detailPath) { + return; + } composeContent.value = res.data; - detailLoading.value = false; }) .finally(() => { + if (currentCompose.value?.name !== name || currentYamlPath.value !== detailPath) { + return; + } loadContainerStats(); detailLoading.value = false; });