From ff5adb081024679633a47535e7cac12e28744af5 Mon Sep 17 00:00:00 2001 From: andoks Date: Wed, 25 Sep 2024 11:59:47 +0200 Subject: [PATCH] Add support for configs.file's and secrets.file's on remote docker hosts Copy configs.file's and secrets.file's instead of bind-mounting them to make it possible to use file configs when working with remote docker hosts (like setting DOCKER_HOST to a ssh address or setting docker context) Includes support for config.files and secrets.files as directories. Note that file.Content as source of secrets is denied elsewhere with the error "validating docker-compose.yml: secrets.content_secret Additional property content is not allowed", but it is implemented here in case this restriction is liften in the future. Configs and secrets from environment is also handled as plain content inserted into target file. implements: #11867 --- pkg/compose/create.go | 128 ---------------------- pkg/compose/secrets.go | 241 ++++++++++++++++++++++++++++++++--------- 2 files changed, 187 insertions(+), 182 deletions(-) diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 639c1fba33e..faf902cbddf 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -941,137 +941,9 @@ func fillBindMounts(p types.Project, s types.ServiceConfig, m map[string]mount.M m[bindMount.Target] = bindMount } - secrets, err := buildContainerSecretMounts(p, s) - if err != nil { - return nil, err - } - for _, s := range secrets { - if _, found := m[s.Target]; found { - continue - } - m[s.Target] = s - } - - configs, err := buildContainerConfigMounts(p, s) - if err != nil { - return nil, err - } - for _, c := range configs { - if _, found := m[c.Target]; found { - continue - } - m[c.Target] = c - } return m, nil } -func buildContainerConfigMounts(p types.Project, s types.ServiceConfig) ([]mount.Mount, error) { - var mounts = map[string]mount.Mount{} - - configsBaseDir := "/" - for _, config := range s.Configs { - target := config.Target - if config.Target == "" { - target = configsBaseDir + config.Source - } else if !isAbsTarget(config.Target) { - target = configsBaseDir + config.Target - } - - definedConfig := p.Configs[config.Source] - if definedConfig.External { - return nil, fmt.Errorf("unsupported external config %s", definedConfig.Name) - } - - if definedConfig.Driver != "" { - return nil, errors.New("Docker Compose does not support configs.*.driver") - } - if definedConfig.TemplateDriver != "" { - return nil, errors.New("Docker Compose does not support configs.*.template_driver") - } - - if definedConfig.Environment != "" || definedConfig.Content != "" { - continue - } - - if config.UID != "" || config.GID != "" || config.Mode != nil { - logrus.Warn("config `uid`, `gid` and `mode` are not supported, they will be ignored") - } - - bindMount, err := buildMount(p, types.ServiceVolumeConfig{ - Type: types.VolumeTypeBind, - Source: definedConfig.File, - Target: target, - ReadOnly: true, - }) - if err != nil { - return nil, err - } - mounts[target] = bindMount - } - values := make([]mount.Mount, 0, len(mounts)) - for _, v := range mounts { - values = append(values, v) - } - return values, nil -} - -func buildContainerSecretMounts(p types.Project, s types.ServiceConfig) ([]mount.Mount, error) { - var mounts = map[string]mount.Mount{} - - secretsDir := "/run/secrets/" - for _, secret := range s.Secrets { - target := secret.Target - if secret.Target == "" { - target = secretsDir + secret.Source - } else if !isAbsTarget(secret.Target) { - target = secretsDir + secret.Target - } - - definedSecret := p.Secrets[secret.Source] - if definedSecret.External { - return nil, fmt.Errorf("unsupported external secret %s", definedSecret.Name) - } - - if definedSecret.Driver != "" { - return nil, errors.New("Docker Compose does not support secrets.*.driver") - } - if definedSecret.TemplateDriver != "" { - return nil, errors.New("Docker Compose does not support secrets.*.template_driver") - } - - if definedSecret.Environment != "" { - continue - } - - if secret.UID != "" || secret.GID != "" || secret.Mode != nil { - logrus.Warn("secrets `uid`, `gid` and `mode` are not supported, they will be ignored") - } - - if _, err := os.Stat(definedSecret.File); os.IsNotExist(err) { - logrus.Warnf("secret file %s does not exist", definedSecret.Name) - } - - mnt, err := buildMount(p, types.ServiceVolumeConfig{ - Type: types.VolumeTypeBind, - Source: definedSecret.File, - Target: target, - ReadOnly: true, - Bind: &types.ServiceVolumeBind{ - CreateHostPath: false, - }, - }) - if err != nil { - return nil, err - } - mounts[target] = mnt - } - values := make([]mount.Mount, 0, len(mounts)) - for _, v := range mounts { - values = append(values, v) - } - return values, nil -} - func isAbsTarget(p string) bool { return isUnixAbs(p) || isWindowsAbs(p) } diff --git a/pkg/compose/secrets.go b/pkg/compose/secrets.go index 4ba49eed445..1e1c1032c7f 100644 --- a/pkg/compose/secrets.go +++ b/pkg/compose/secrets.go @@ -20,38 +20,64 @@ import ( "archive/tar" "bytes" "context" + "errors" "fmt" + "io" + "io/fs" + "os" + "path/filepath" "strconv" "time" "github.com/compose-spec/compose-go/v2/types" - "github.com/docker/docker/api/types/container" + moby "github.com/docker/docker/api/types/container" ) func (s *composeService) injectSecrets(ctx context.Context, project *types.Project, service types.ServiceConfig, id string) error { - for _, config := range service.Secrets { - file := project.Secrets[config.Source] - if file.Environment == "" { - continue + const secretsBaseDir = "/run/secrets" + for _, secret := range service.Secrets { + if secret.Target == "" { + secret.Target = secretsBaseDir + secret.Source + } else if !isAbsTarget(secret.Target) { + secret.Target = secretsBaseDir + secret.Target } - if config.Target == "" { - config.Target = "/run/secrets/" + config.Source - } else if !isAbsTarget(config.Target) { - config.Target = "/run/secrets/" + config.Target + definedSecret := project.Secrets[secret.Source] + if definedSecret.Driver != "" { + return errors.New("docker compose does not support secrets.*.driver") + } + if definedSecret.TemplateDriver != "" { + return errors.New("docker compose does not support secrets.*.template_driver") } - env, ok := project.Environment[file.Environment] - if !ok { - return fmt.Errorf("environment variable %q required by file %q is not set", file.Environment, file.Name) + var tarArchive bytes.Buffer + var err error + switch { + case definedSecret.External == true: + err = fmt.Errorf("unsupported external secret %s", definedSecret.Name) + case definedSecret.Content != "": + tarArchive, err = createTarredFileOf(definedSecret.Content, types.FileReferenceConfig(secret)) + case definedSecret.File != "": + tarArchive, err = createTarArchiveOf(definedSecret.File, types.FileReferenceConfig(secret)) + case definedSecret.Environment != "": + env, ok := project.Environment[definedSecret.Environment] + if !ok { + return fmt.Errorf("environment variable %q required by file %q is not set", definedSecret.Environment, definedSecret.Name) + } + tarArchive, err = createTarredFileOf(env, types.FileReferenceConfig(secret)) } - b, err := createTar(env, types.FileReferenceConfig(config)) + if err != nil { return err } - err = s.apiClient().CopyToContainer(ctx, id, "/", &b, container.CopyToContainerOptions{ - CopyUIDGID: config.UID != "" || config.GID != "", + // secret was handled elsewhere (e.g it was external) + if tarArchive.Len() == 0 { + continue + } + + err = s.apiClient().CopyToContainer(ctx, id, "/", &tarArchive, moby.CopyToContainerOptions{ + CopyUIDGID: secret.UID != "" || secret.GID != "", }) if err != nil { return err @@ -61,30 +87,49 @@ func (s *composeService) injectSecrets(ctx context.Context, project *types.Proje } func (s *composeService) injectConfigs(ctx context.Context, project *types.Project, service types.ServiceConfig, id string) error { + const configsBaseDir = "/" for _, config := range service.Configs { - file := project.Configs[config.Source] - content := file.Content - if file.Environment != "" { - env, ok := project.Environment[file.Environment] - if !ok { - return fmt.Errorf("environment variable %q required by file %q is not set", file.Environment, file.Name) - } - content = env + if config.Target == "" { + config.Target = configsBaseDir + config.Source + } else if !isAbsTarget(config.Target) { + config.Target = configsBaseDir + config.Target } - if content == "" { - continue + + definedConfig := project.Configs[config.Source] + if definedConfig.Driver != "" { + return errors.New("docker compose does not support configs.*.driver") + } + if definedConfig.TemplateDriver != "" { + return errors.New("docker compose does not support configs.*.template_driver") } - if config.Target == "" { - config.Target = "/" + config.Source + var tarArchive bytes.Buffer + var err error + switch { + case definedConfig.External == true: + err = fmt.Errorf("unsupported external config %s", definedConfig.Name) + case definedConfig.File != "": + tarArchive, err = createTarArchiveOf(definedConfig.File, types.FileReferenceConfig(config)) + case definedConfig.Content != "": + tarArchive, err = createTarredFileOf(definedConfig.Content, types.FileReferenceConfig(config)) + case definedConfig.Environment != "": + env, ok := project.Environment[definedConfig.Environment] + if !ok { + return fmt.Errorf("environment variable %q required by file %q is not set", definedConfig.Environment, definedConfig.Name) + } + tarArchive, err = createTarredFileOf(env, types.FileReferenceConfig(config)) } - b, err := createTar(content, types.FileReferenceConfig(config)) if err != nil { return err } - err = s.apiClient().CopyToContainer(ctx, id, "/", &b, container.CopyToContainerOptions{ + // config was handled elsewhere (e.g it was external) + if tarArchive.Len() == 0 { + continue + } + + err = s.apiClient().CopyToContainer(ctx, id, "/", &tarArchive, moby.CopyToContainerOptions{ CopyUIDGID: config.UID != "" || config.GID != "", }) if err != nil { @@ -94,47 +139,135 @@ func (s *composeService) injectConfigs(ctx context.Context, project *types.Proje return nil } -func createTar(env string, config types.FileReferenceConfig) (bytes.Buffer, error) { - value := []byte(env) +func createTarredFileOf(value string, config types.FileReferenceConfig) (bytes.Buffer, error) { + mode, uid, gid, err := makeTarFileEntryParams(config) + if err != nil { + return bytes.Buffer{}, fmt.Errorf("failed parsing target file parameters") + } + b := bytes.Buffer{} tarWriter := tar.NewWriter(&b) - mode := uint32(0o444) + valueAsBytes := []byte(value) + header := &tar.Header{ + Name: config.Target, + Size: int64(len(valueAsBytes)), + Mode: mode, + ModTime: time.Now(), + Uid: uid, + Gid: gid, + } + err = tarWriter.WriteHeader(header) + if err != nil { + return bytes.Buffer{}, err + } + _, err = tarWriter.Write(valueAsBytes) + if err != nil { + return bytes.Buffer{}, err + } + err = tarWriter.Close() + return b, err +} + +func createTarArchiveOf(path string, config types.FileReferenceConfig) (bytes.Buffer, error) { + // need to treat files and directories differently + fi, err := os.Stat(path) + if err != nil { + return bytes.Buffer{}, err + } + + // if path is not directory, try to treat it as a file by reading its value + if !fi.IsDir() { + buf, err := os.ReadFile(path) + if err == nil { + return createTarredFileOf(string(buf), config) + } + } + + mode, uid, gid, err := makeTarFileEntryParams(config) + if err != nil { + return bytes.Buffer{}, fmt.Errorf("failed parsing target file parameters") + } + + subdir := os.DirFS(path) + b := bytes.Buffer{} + tarWriter := tar.NewWriter(&b) + + // build the tar by walking instead of using archive/tar.Writer.AddFS to be able to adjust mode, gid and uid + err = fs.WalkDir(subdir, ".", func(filePath string, d fs.DirEntry, err error) error { + header := &tar.Header{ + Name: filepath.Join(config.Target, filePath), + Mode: mode, + ModTime: time.Now(), + Uid: uid, + Gid: gid, + } + + if d.IsDir() { + // tar requires that directory headers ends with a slash + header.Name = header.Name + "/" + err = tarWriter.WriteHeader(header) + if err != nil { + return fmt.Errorf("failed writing tar header of directory %v while walking diretory structure, error was: %w", filePath, err) + } + } else { + f, err := subdir.Open(filePath) + if err != nil { + return err + } + defer f.Close() + + valueAsBytes, err := io.ReadAll(f) + if err != nil { + return fmt.Errorf("failed reading file %v for to send to container, error was: %w", filePath, err) + } + + header.Size = int64(len(valueAsBytes)) + err = tarWriter.WriteHeader(header) + if err != nil { + return fmt.Errorf("failed writing tar header for file %v while walking diretory structure, error was: %w", filePath, err) + } + + _, err = tarWriter.Write(valueAsBytes) + if err != nil { + return fmt.Errorf("failed writing file content of %v into tar archive while walking directory structure, error was: %w", filePath, err) + } + } + + return nil + }) + + if err != nil { + return bytes.Buffer{}, fmt.Errorf("failed building tar archive while walking config directory structure, error was: %w", err) + } + + err = tarWriter.Close() + if err != nil { + return bytes.Buffer{}, fmt.Errorf("failed closing tar archive after writing, error was: %w", err) + } + + return b, err +} + +func makeTarFileEntryParams(config types.FileReferenceConfig) (mode int64, uid, gid int, err error) { + mode = 0o444 if config.Mode != nil { - mode = *config.Mode + mode = int64(*config.Mode) } - var uid, gid int if config.UID != "" { v, err := strconv.Atoi(config.UID) if err != nil { - return b, err + return 0, 0, 0, err } uid = v } if config.GID != "" { v, err := strconv.Atoi(config.GID) if err != nil { - return b, err + return 0, 0, 0, err } gid = v } - header := &tar.Header{ - Name: config.Target, - Size: int64(len(value)), - Mode: int64(mode), - ModTime: time.Now(), - Uid: uid, - Gid: gid, - } - err := tarWriter.WriteHeader(header) - if err != nil { - return bytes.Buffer{}, err - } - _, err = tarWriter.Write(value) - if err != nil { - return bytes.Buffer{}, err - } - err = tarWriter.Close() - return b, err + return mode, uid, gid, nil }