fix(configs): Create non-existent parent directories automatically (#233)

This commit is contained in:
Anton Ovchinnikov
2025-12-29 11:38:06 +01:00
committed by GitHub
parent 57e2e22266
commit 2c34ba7eff
7 changed files with 193 additions and 21 deletions
+2
View File
@@ -7,6 +7,8 @@
*.dll *.dll
*.so *.so
*.dylib *.dylib
/uncloud
/uncloudd
# OS X # OS X
.DS_Store .DS_Store
+9 -10
View File
@@ -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. // 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 { 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 var buf bytes.Buffer
tw := tar.NewWriter(&buf) 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{ header := &tar.Header{
Name: filepath.Base(targetPath), Name: tarPath,
Size: int64(len(content)), Size: int64(len(content)),
Mode: int64(fileMode), Mode: int64(fileMode),
ModTime: time.Now(), ModTime: time.Now(),
@@ -834,16 +837,12 @@ func (s *Server) copyContentToContainer(ctx context.Context, containerID string,
return fmt.Errorf("close tar writer: %w", err) return fmt.Errorf("close tar writer: %w", err)
} }
// Copy the tar archive to the container // Always extract to root. The tar archive contains the full path, so tar will
targetDir := filepath.Dir(targetPath) // automatically create any intermediate directories that don't exist in the container.
if targetDir == "." {
targetDir = "/"
}
if err := s.client.CopyToContainer( if err := s.client.CopyToContainer(
ctx, ctx,
containerID, containerID,
targetDir, "/",
&buf, &buf,
container.CopyToContainerOptions{CopyUIDGID: true}, container.CopyToContainerOptions{CopyUIDGID: true},
); err != nil { ); err != nil {
+19 -6
View File
@@ -13,18 +13,27 @@ import (
func configSpecsFromCompose( func configSpecsFromCompose(
configs types.Configs, serviceConfigs []types.ServiceConfigObjConfig, workingDir string, configs types.Configs, serviceConfigs []types.ServiceConfigObjConfig, workingDir string,
) ([]api.ConfigSpec, []api.ConfigMount, error) { ) ([]api.ConfigSpec, []api.ConfigMount, error) {
var configSpecs []api.ConfigSpec
var configMounts []api.ConfigMount 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 { for _, serviceConfig := range serviceConfigs {
var spec api.ConfigSpec var spec api.ConfigSpec
if projectConfig, exists := configs[serviceConfig.Source]; exists { projectConfig, exists := configs[serviceConfig.Source]
if !exists {
return nil, nil, fmt.Errorf("config '%s' not found in project configs", serviceConfig.Source)
}
if projectConfig.External { if projectConfig.External {
return nil, nil, fmt.Errorf("external configs are not supported: %s", return nil, nil, fmt.Errorf("external configs are not supported: %s",
serviceConfig.Source) serviceConfig.Source)
} }
spec, exists = configSpecsMap[serviceConfig.Source]
if !exists {
spec = api.ConfigSpec{ spec = api.ConfigSpec{
Name: serviceConfig.Source, Name: serviceConfig.Source,
Content: []byte(projectConfig.Content), Content: []byte(projectConfig.Content),
@@ -44,11 +53,9 @@ func configSpecsFromCompose(
} }
spec.Content = fileContent 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 // Create config mount
target := serviceConfig.Target target := serviceConfig.Target
@@ -71,5 +78,11 @@ func configSpecsFromCompose(
configMounts = append(configMounts, mount) 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 return configSpecs, configMounts, nil
} }
+141
View File
@@ -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 { for _, tt := range tests {
+14
View File
@@ -66,6 +66,11 @@ func TestComposeConfigs(t *testing.T) {
Gid: "1000", Gid: "1000",
Mode: func() *os.FileMode { m := os.FileMode(0o600); return &m }(), 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{ Configs: []api.ConfigSpec{
@@ -110,5 +115,14 @@ func TestComposeConfigs(t *testing.T) {
userId: 1000, userId: 1000,
groupId: 1000, groupId: 1000,
}, configContentSecond) }, 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")
}) })
} }
+4
View File
@@ -11,6 +11,10 @@ services:
uid: "1000" uid: "1000"
gid: "1000" gid: "1000"
mode: 0600 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: deploy:
replicas: 1 replicas: 1
configs: configs:
-1
View File
@@ -71,7 +71,6 @@ func readFileInfoInContainer(t *testing.T, cli *client.Client, serviceNameOrID,
// Parse permissions // Parse permissions
permissions := strings.TrimSpace(permOutput) permissions := strings.TrimSpace(permOutput)
fmt.Printf("Permissions output: %s\n", permissions)
// Parse three numbers: permissions, uid, gid // Parse three numbers: permissions, uid, gid
var permissionsOctal, uid, gid int var permissionsOctal, uid, gid int