From 2c34ba7effc545b35c1a2295b92e09cecec57d48 Mon Sep 17 00:00:00 2001 From: Anton Ovchinnikov Date: Mon, 29 Dec 2025 11:38:06 +0100 Subject: [PATCH] fix(configs): Create non-existent parent directories automatically (#233) --- .gitignore | 2 + internal/machine/docker/server.go | 19 ++-- pkg/client/compose/config.go | 33 ++++-- pkg/client/compose/config_test.go | 141 +++++++++++++++++++++++++ test/e2e/compose_configs_test.go | 14 +++ test/e2e/fixtures/compose-configs.yaml | 4 + test/e2e/helpers.go | 1 - 7 files changed, 193 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index c813b569..c36b8fab 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ *.dll *.so *.dylib +/uncloud +/uncloudd # OS X .DS_Store diff --git a/internal/machine/docker/server.go b/internal/machine/docker/server.go index 7787d3ae..65baa6bc 100644 --- a/internal/machine/docker/server.go +++ b/internal/machine/docker/server.go @@ -801,14 +801,17 @@ func (s *Server) injectConfigs(ctx context.Context, containerID string, configs } // copyContentToContainer copies content directly to a file in the container using Docker's CopyToContainer API. +// It will create any intermediate directories in the target path that don't exist. func (s *Server) copyContentToContainer(ctx context.Context, containerID string, content []byte, targetPath string, uid *uint64, gid *uint64, fileMode os.FileMode) error { - // Create a tar archive containing the file var buf bytes.Buffer tw := tar.NewWriter(&buf) - // Create tar header + // Trim leading slash(es) to avoid double slashes in the tar path + tarPath := strings.TrimPrefix(targetPath, "/") + + // Create tar header with full path header := &tar.Header{ - Name: filepath.Base(targetPath), + Name: tarPath, Size: int64(len(content)), Mode: int64(fileMode), ModTime: time.Now(), @@ -834,16 +837,12 @@ func (s *Server) copyContentToContainer(ctx context.Context, containerID string, return fmt.Errorf("close tar writer: %w", err) } - // Copy the tar archive to the container - targetDir := filepath.Dir(targetPath) - if targetDir == "." { - targetDir = "/" - } - + // Always extract to root. The tar archive contains the full path, so tar will + // automatically create any intermediate directories that don't exist in the container. if err := s.client.CopyToContainer( ctx, containerID, - targetDir, + "/", &buf, container.CopyToContainerOptions{CopyUIDGID: true}, ); err != nil { diff --git a/pkg/client/compose/config.go b/pkg/client/compose/config.go index 23bd31c8..46c71522 100644 --- a/pkg/client/compose/config.go +++ b/pkg/client/compose/config.go @@ -13,18 +13,27 @@ import ( func configSpecsFromCompose( configs types.Configs, serviceConfigs []types.ServiceConfigObjConfig, workingDir string, ) ([]api.ConfigSpec, []api.ConfigMount, error) { - var configSpecs []api.ConfigSpec var configMounts []api.ConfigMount + // Temporary map to hold config specs + configSpecsMap := make(map[string]api.ConfigSpec) + + // We iterate over all service config objects (config mounts) for _, serviceConfig := range serviceConfigs { var spec api.ConfigSpec - if projectConfig, exists := configs[serviceConfig.Source]; exists { - if projectConfig.External { - return nil, nil, fmt.Errorf("external configs are not supported: %s", - serviceConfig.Source) - } + projectConfig, exists := configs[serviceConfig.Source] + if !exists { + return nil, nil, fmt.Errorf("config '%s' not found in project configs", serviceConfig.Source) + } + if projectConfig.External { + return nil, nil, fmt.Errorf("external configs are not supported: %s", + serviceConfig.Source) + } + + spec, exists = configSpecsMap[serviceConfig.Source] + if !exists { spec = api.ConfigSpec{ Name: serviceConfig.Source, Content: []byte(projectConfig.Content), @@ -44,11 +53,9 @@ func configSpecsFromCompose( } spec.Content = fileContent } - } else { - return nil, nil, fmt.Errorf("config '%s' not found in project configs", serviceConfig.Source) - } - configSpecs = append(configSpecs, spec) + configSpecsMap[serviceConfig.Source] = spec + } // Create config mount target := serviceConfig.Target @@ -71,5 +78,11 @@ func configSpecsFromCompose( configMounts = append(configMounts, mount) } + var configSpecs []api.ConfigSpec + // Convert config spec map to slice + for _, spec := range configSpecsMap { + configSpecs = append(configSpecs, spec) + } + return configSpecs, configMounts, nil } diff --git a/pkg/client/compose/config_test.go b/pkg/client/compose/config_test.go index 7aaf7d11..bab2e8c1 100644 --- a/pkg/client/compose/config_test.go +++ b/pkg/client/compose/config_test.go @@ -77,6 +77,147 @@ func TestConfigSpecsFromCompose(t *testing.T) { }, }, }, + { + name: "same source mounted to different targets", + configs: types.Configs{ + "shared-config": types.ConfigObjConfig{ + File: "testdata/config1.txt", + }, + }, + serviceConfigs: []types.ServiceConfigObjConfig{ + { + Source: "shared-config", + Target: "/app/config.json", + UID: "1000", + GID: "1000", + }, + { + Source: "shared-config", + Target: "/backup/config.json", + UID: "1001", + GID: "1001", + }, + }, + expectedSpecs: []api.ConfigSpec{ + { + Name: "shared-config", + Content: []byte("test config content\n"), + }, + }, + expectedMounts: []api.ConfigMount{ + { + ConfigName: "shared-config", + ContainerPath: "/app/config.json", + Uid: "1000", + Gid: "1000", + }, + { + ConfigName: "shared-config", + ContainerPath: "/backup/config.json", + Uid: "1001", + Gid: "1001", + }, + }, + }, + { + name: "config with default target path", + configs: types.Configs{ + "default-config": types.ConfigObjConfig{ + Content: "inline config content", + }, + }, + serviceConfigs: []types.ServiceConfigObjConfig{ + { + Source: "default-config", + // No Target specified - should use default + }, + }, + expectedSpecs: []api.ConfigSpec{ + { + Name: "default-config", + Content: []byte("inline config content"), + }, + }, + expectedMounts: []api.ConfigMount{ + { + ConfigName: "default-config", + ContainerPath: "/default-config", + }, + }, + }, + { + name: "config with inline content", + configs: types.Configs{ + "inline-config": types.ConfigObjConfig{ + Content: "server {\n listen 80;\n}", + }, + }, + serviceConfigs: []types.ServiceConfigObjConfig{ + { + Source: "inline-config", + Target: "/etc/nginx/sites-available/default", + Mode: func() *types.FileMode { m := types.FileMode(0o755); return &m }(), + }, + }, + expectedSpecs: []api.ConfigSpec{ + { + Name: "inline-config", + Content: []byte("server {\n listen 80;\n}"), + }, + }, + expectedMounts: []api.ConfigMount{ + { + ConfigName: "inline-config", + ContainerPath: "/etc/nginx/sites-available/default", + Mode: func() *os.FileMode { m := os.FileMode(0o755); return &m }(), + }, + }, + }, + { + name: "config not found error", + configs: types.Configs{ + "existing-config": types.ConfigObjConfig{ + Content: "some content", + }, + }, + serviceConfigs: []types.ServiceConfigObjConfig{ + { + Source: "missing-config", + Target: "/app/config.json", + }, + }, + expectError: true, + }, + { + name: "external config error", + configs: types.Configs{ + "external-config": types.ConfigObjConfig{ + External: true, + }, + }, + serviceConfigs: []types.ServiceConfigObjConfig{ + { + Source: "external-config", + Target: "/app/config.json", + }, + }, + expectError: true, + }, + { + name: "file not found error", + configs: types.Configs{ + "missing-file-config": types.ConfigObjConfig{ + File: "testdata/nonexistent.txt", + }, + }, + serviceConfigs: []types.ServiceConfigObjConfig{ + { + Source: "missing-file-config", + Target: "/app/config.json", + }, + }, + expectError: true, + }, } for _, tt := range tests { diff --git a/test/e2e/compose_configs_test.go b/test/e2e/compose_configs_test.go index 76456219..8a4b2b19 100644 --- a/test/e2e/compose_configs_test.go +++ b/test/e2e/compose_configs_test.go @@ -66,6 +66,11 @@ func TestComposeConfigs(t *testing.T) { Gid: "1000", Mode: func() *os.FileMode { m := os.FileMode(0o600); return &m }(), }, + { + ConfigName: "from-file", + ContainerPath: "/etc/new-dir/config-from-file.conf", + Mode: func() *os.FileMode { m := os.FileMode(0o644); return &m }(), + }, }, }, Configs: []api.ConfigSpec{ @@ -110,5 +115,14 @@ func TestComposeConfigs(t *testing.T) { userId: 1000, groupId: 1000, }, configContentSecond) + + configContentThird, err := readFileInfoInContainer(t, cli, name, containerName, "/etc/new-dir/config-from-file.conf") + require.NoError(t, err) + assert.Equal(t, fileInfo{ + permissions: 0o644, + content: "this is file config\n", + userId: 0, + groupId: 0, + }, configContentThird, "Same config should be mountable to multiple paths, including nested directories") }) } diff --git a/test/e2e/fixtures/compose-configs.yaml b/test/e2e/fixtures/compose-configs.yaml index b420deab..a7fbfcfd 100644 --- a/test/e2e/fixtures/compose-configs.yaml +++ b/test/e2e/fixtures/compose-configs.yaml @@ -11,6 +11,10 @@ services: uid: "1000" gid: "1000" mode: 0600 + # Writing a config to a new, non-existent directory + - source: from-file + target: /etc/new-dir/config-from-file.conf + mode: 0644 deploy: replicas: 1 configs: diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go index 07ad5b60..bf2efc27 100644 --- a/test/e2e/helpers.go +++ b/test/e2e/helpers.go @@ -71,7 +71,6 @@ func readFileInfoInContainer(t *testing.T, cli *client.Client, serviceNameOrID, // Parse permissions permissions := strings.TrimSpace(permOutput) - fmt.Printf("Permissions output: %s\n", permissions) // Parse three numbers: permissions, uid, gid var permissionsOctal, uid, gid int