fix(compose): handle relative paths and preserve multi-file projects (#14062)

This commit is contained in:
ssongliu
2026-10-13 11:03:17 +08:00
committed by GitHub
parent fd12866897
commit 3011383b07
4 changed files with 195 additions and 123 deletions
+84 -48
View File
@@ -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
}
+31 -24
View File
@@ -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
+71 -49
View File
@@ -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
}
@@ -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;
});