Compare commits

...

15 Commits

Author SHA1 Message Date
Guillaume Lours
49b1c1e932 bump compose-go to version v2.9.1
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
(cherry picked from commit 3eb2934eb7)
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
2025-10-30 10:14:58 +01:00
Suleiman Dibirov
19351bbd7d fix compose_run_build_once_test.go
Signed-off-by: Suleiman Dibirov <idsulik@gmail.com>
(cherry picked from commit c416ea7036)
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
2025-10-30 10:12:21 +01:00
Suleiman Dibirov
3de9b0cc71 fix(git): Add validation for Git subdirectory paths to prevent traversal
Signed-off-by: Suleiman Dibirov <idsulik@gmail.com>
(cherry picked from commit 0d396bbacb)
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
2025-10-30 10:12:04 +01:00
Suleiman Dibirov
1d4acfb5fa Update e2e tests in compose_run_build_once_test.go to use project names for Docker Compose commands.
Signed-off-by: Suleiman Dibirov <idsulik@gmail.com>
(cherry picked from commit fc74c78963)
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
2025-10-30 10:11:17 +01:00
Suleiman Dibirov
670a181f77 Revert "no parallel in compose_run_build_once_test.go"
This reverts commit e4f4a5aa86.

Signed-off-by: Suleiman Dibirov <idsulik@gmail.com>
(cherry picked from commit 658bff335f)
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
2025-10-30 10:10:56 +01:00
Suleiman Dibirov
739ae1b773 no parallel in compose_run_build_once_test.go
Signed-off-by: Suleiman Dibirov <idsulik@gmail.com>
(cherry picked from commit 80030e1390)
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
2025-10-30 10:10:43 +01:00
Suleiman Dibirov
141921e72b lint fix
Signed-off-by: Suleiman Dibirov <idsulik@gmail.com>
(cherry picked from commit 6a35be5112)
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
2025-10-30 10:10:19 +01:00
Suleiman Dibirov
c6bec2e712 add e2e tests
Signed-off-by: Suleiman Dibirov <idsulik@gmail.com>
(cherry picked from commit 0c854a6ab7)
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
2025-10-30 10:09:52 +01:00
Suleiman Dibirov
0bd132b547 fix(run): Ensure images exist only for the target service in run command
Signed-off-by: Suleiman Dibirov <idsulik@gmail.com>
(cherry picked from commit 557e0b6ec7)
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
2025-10-30 10:09:29 +01:00
Nicolas De Loof
ee86f5527c run hooks on restart
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
(cherry picked from commit 5924387e89)
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
2025-10-30 09:58:34 +01:00
Nicolas De Loof
d0f2c3504a prompt default implementation to prevent a panic
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
(cherry picked from commit 3ce52883cb)
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
2025-10-30 09:57:38 +01:00
Nicolas De Loof
db1c279447 Code Cleanup
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
(cherry picked from commit ac3b8fd8a5)
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
2025-10-30 09:57:17 +01:00
Anton Ovchinnikov
5e209ea113 Fix help output for "exec --no-tty" option
Signed-off-by: Anton Ovchinnikov <anton@tonyo.info>
(cherry picked from commit 8619f5d72a)
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
2025-10-30 09:55:53 +01:00
Nicolas De Loof
5dec597d4b fix OCI compose override support
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
(cherry picked from commit e59150baa8)
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
2025-10-30 09:55:19 +01:00
Nicolas De Loof
47f6d02bef Test to check writeComposeFile detects invalid OCI artifact
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
(cherry picked from commit 6a90742ef2)
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
2025-10-30 09:54:58 +01:00
32 changed files with 518 additions and 42 deletions

View File

@@ -82,7 +82,7 @@ func execCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Compose)
runCmd.Flags().IntVar(&opts.index, "index", 0, "Index of the container if service has multiple replicas")
runCmd.Flags().BoolVarP(&opts.privileged, "privileged", "", false, "Give extended privileges to the process")
runCmd.Flags().StringVarP(&opts.user, "user", "u", "", "Run the command as this user")
runCmd.Flags().BoolVarP(&opts.noTty, "no-tty", "T", !dockerCli.Out().IsTerminal(), "Disable pseudo-TTY allocation. By default `docker compose exec` allocates a TTY.")
runCmd.Flags().BoolVarP(&opts.noTty, "no-tty", "T", !dockerCli.Out().IsTerminal(), "Disable pseudo-TTY allocation. By default 'docker compose exec' allocates a TTY.")
runCmd.Flags().StringVarP(&opts.workingDir, "workdir", "w", "", "Path to workdir directory for this command")
runCmd.Flags().BoolVarP(&opts.interactive, "interactive", "i", true, "Keep STDIN open even if not attached")

View File

@@ -22,4 +22,4 @@ package formatter
func handleCtrlZ() {
// Windows doesn't support SIGSTOP/SIGCONT signals
// Ctrl+Z behavior is handled differently by the Windows terminal
}
}

View File

@@ -20,7 +20,7 @@ a script.
| `--dry-run` | `bool` | | Execute command in dry run mode |
| `-e`, `--env` | `stringArray` | | Set environment variables |
| `--index` | `int` | `0` | Index of the container if service has multiple replicas |
| `-T`, `--no-tty` | `bool` | `true` | Disable pseudo-TTY allocation. By default `docker compose exec` allocates a TTY. |
| `-T`, `--no-tty` | `bool` | `true` | Disable pseudo-TTY allocation. By default 'docker compose exec' allocates a TTY. |
| `--privileged` | `bool` | | Give extended privileges to the process |
| `-u`, `--user` | `string` | | Run the command as this user |
| `-w`, `--workdir` | `string` | | Path to workdir directory for this command |

View File

@@ -63,7 +63,7 @@ options:
value_type: bool
default_value: "true"
description: |
Disable pseudo-TTY allocation. By default `docker compose exec` allocates a TTY.
Disable pseudo-TTY allocation. By default 'docker compose exec' allocates a TTY.
deprecated: false
hidden: false
experimental: false

2
go.mod
View File

@@ -8,7 +8,7 @@ require (
github.com/Microsoft/go-winio v0.6.2
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
github.com/buger/goterm v1.0.4
github.com/compose-spec/compose-go/v2 v2.9.0
github.com/compose-spec/compose-go/v2 v2.9.1
github.com/containerd/containerd/v2 v2.1.4
github.com/containerd/errdefs v1.0.0
github.com/containerd/platforms v1.0.0-rc.1

4
go.sum
View File

@@ -78,8 +78,8 @@ github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
github.com/compose-spec/compose-go/v2 v2.9.0 h1:UHSv/QHlo6QJtrT4igF1rdORgIUhDo1gWuyJUoiNNIM=
github.com/compose-spec/compose-go/v2 v2.9.0/go.mod h1:Oky9AZGTRB4E+0VbTPZTUu4Kp+oEMMuwZXZtPPVT1iE=
github.com/compose-spec/compose-go/v2 v2.9.1 h1:8UwI+ujNU+9Ffkf/YgAm/qM9/eU7Jn8nHzWG721W4rs=
github.com/compose-spec/compose-go/v2 v2.9.1/go.mod h1:Oky9AZGTRB4E+0VbTPZTUu4Kp+oEMMuwZXZtPPVT1iE=
github.com/containerd/cgroups/v3 v3.0.5 h1:44na7Ud+VwyE7LIoJ8JTNQOa549a8543BmzaJHo6Bzo=
github.com/containerd/cgroups/v3 v3.0.5/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins=
github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc=

View File

@@ -20,6 +20,7 @@ import (
"context"
"io"
"net/url"
"os"
"strings"
"github.com/containerd/containerd/v2/core/remotes"
@@ -50,6 +51,11 @@ func NewResolver(config *configfile.ConfigFile) remotes.Resolver {
return auth.Username, auth.Password, nil
}),
)),
docker.WithPlainHTTP(func(s string) (bool, error) {
// Used for testing **only**
_, b := os.LookupEnv("__TEST__INSECURE__REGISTRY__")
return b, nil
}),
),
})
}

View File

@@ -45,7 +45,7 @@ func (s *composeService) useAPISocket(project *types.Project) (*types.Project, e
return nil, errors.New("use_api_socket can't be used with a Windows Docker Engine")
}
creds, err := s.dockerCli.ConfigFile().GetAllCredentials()
creds, err := s.configFile().GetAllCredentials()
if err != nil {
return nil, fmt.Errorf("resolving credentials failed: %w", err)
}

View File

@@ -40,7 +40,7 @@ func (s *composeService) commit(ctx context.Context, projectName string, options
return err
}
clnt := s.dockerCli.Client()
clnt := s.apiClient()
w := progress.ContextWriter(ctx)

View File

@@ -37,6 +37,7 @@ import (
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
"github.com/jonboulle/clockwork"
"github.com/sirupsen/logrus"
"github.com/docker/compose/v2/pkg/api"
)
@@ -63,6 +64,13 @@ func NewComposeService(dockerCli command.Cli, options ...Option) api.Compose {
for _, option := range options {
option(s)
}
if s.prompt == nil {
s.prompt = func(message string, defaultValue bool) (bool, error) {
fmt.Println(message)
logrus.Warning("Compose is running without a 'prompt' component to interact with user")
return defaultValue, nil
}
}
return s
}
@@ -92,7 +100,7 @@ type composeService struct {
func (s *composeService) Close() error {
var errs []error
if s.dockerCli != nil {
errs = append(errs, s.dockerCli.Client().Close())
errs = append(errs, s.apiClient().Close())
}
return errors.Join(errs...)
}
@@ -323,7 +331,7 @@ var runtimeVersion runtimeVersionCache
func (s *composeService) RuntimeVersion(ctx context.Context) (string, error) {
runtimeVersion.once.Do(func() {
version, err := s.dockerCli.Client().ServerVersion(ctx)
version, err := s.apiClient().ServerVersion(ctx)
if err != nil {
runtimeVersion.err = err
}

View File

@@ -50,7 +50,7 @@ func (s *composeService) export(ctx context.Context, projectName string, options
return fmt.Errorf("failed to export container: %w", err)
}
clnt := s.dockerCli.Client()
clnt := s.apiClient()
w := progress.ContextWriter(ctx)

View File

@@ -90,8 +90,7 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
return err
}
config := s.dockerCli.ConfigFile()
resolver := oci.NewResolver(config)
resolver := oci.NewResolver(s.configFile())
descriptor, err := oci.PushManifest(ctx, resolver, named, layers, options.OCIVersion)
if err != nil {

View File

@@ -77,7 +77,8 @@ services:
MediaType: "application/vnd.docker.compose.file+yaml",
Annotations: map[string]string{
"com.docker.compose.file": "compose.yaml",
"com.docker.compose.version": internal.Version},
"com.docker.compose.version": internal.Version,
},
},
{
MediaType: "application/vnd.docker.compose.file+yaml",
@@ -98,5 +99,4 @@ services:
assert.DeepEqual(t, expectedLayers, layers, cmp.FilterPath(func(path cmp.Path) bool {
return !slices.Contains([]string{".Data", ".Digest", ".Size"}, path.String())
}, cmp.Ignore()))
}

View File

@@ -116,7 +116,7 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts
idx := i
eg.Go(func() error {
_, err := s.pullServiceImage(ctx, service, s.configFile(), w, opts.Quiet, project.Environment["DOCKER_DEFAULT_PLATFORM"])
_, err := s.pullServiceImage(ctx, service, w, opts.Quiet, project.Environment["DOCKER_DEFAULT_PLATFORM"])
if err != nil {
pullErrors[idx] = err
if service.Build != nil {
@@ -177,9 +177,7 @@ func getUnwrappedErrorMessage(err error) string {
return err.Error()
}
func (s *composeService) pullServiceImage(ctx context.Context, service types.ServiceConfig,
configFile driver.Auth, w progress.Writer, quietPull bool, defaultPlatform string,
) (string, error) {
func (s *composeService) pullServiceImage(ctx context.Context, service types.ServiceConfig, w progress.Writer, quietPull bool, defaultPlatform string) (string, error) {
w.Event(progress.Event{
ID: service.Name,
Status: progress.Working,
@@ -190,7 +188,7 @@ func (s *composeService) pullServiceImage(ctx context.Context, service types.Ser
return "", err
}
encodedAuth, err := encodedAuth(ref, configFile)
encodedAuth, err := encodedAuth(ref, s.configFile())
if err != nil {
return "", err
}
@@ -330,7 +328,7 @@ func (s *composeService) pullRequiredImages(ctx context.Context, project *types.
var mutex sync.Mutex
for name, service := range needPull {
eg.Go(func() error {
id, err := s.pullServiceImage(ctx, service, s.configFile(), w, quietPull, project.Environment["DOCKER_DEFAULT_PLATFORM"])
id, err := s.pullServiceImage(ctx, service, w, quietPull, project.Environment["DOCKER_DEFAULT_PLATFORM"])
mutex.Lock()
defer mutex.Unlock()
pulledImages[name] = api.ImageSummary{

View File

@@ -27,7 +27,6 @@ import (
"github.com/compose-spec/compose-go/v2/types"
"github.com/distribution/reference"
"github.com/docker/buildx/driver"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/pkg/jsonmessage"
"golang.org/x/sync/errgroup"
@@ -70,7 +69,7 @@ func (s *composeService) push(ctx context.Context, project *types.Project, optio
for _, tag := range tags {
eg.Go(func() error {
err := s.pushServiceImage(ctx, tag, s.configFile(), w, options.Quiet)
err := s.pushServiceImage(ctx, tag, w, options.Quiet)
if err != nil {
if !options.IgnoreFailures {
return err
@@ -84,13 +83,13 @@ func (s *composeService) push(ctx context.Context, project *types.Project, optio
return eg.Wait()
}
func (s *composeService) pushServiceImage(ctx context.Context, tag string, configFile driver.Auth, w progress.Writer, quietPush bool) error {
func (s *composeService) pushServiceImage(ctx context.Context, tag string, w progress.Writer, quietPush bool) error {
ref, err := reference.ParseNormalizedNamed(tag)
if err != nil {
return err
}
authConfig, err := configFile.GetAuthConfig(registry.GetAuthConfigKey(reference.Domain(ref)))
authConfig, err := s.configFile().GetAuthConfig(registry.GetAuthConfigKey(reference.Domain(ref)))
if err != nil {
return err
}

View File

@@ -34,7 +34,7 @@ func (s *composeService) Restart(ctx context.Context, projectName string, option
}, s.stdinfo(), "Restarting")
}
func (s *composeService) restart(ctx context.Context, projectName string, options api.RestartOptions) error {
func (s *composeService) restart(ctx context.Context, projectName string, options api.RestartOptions) error { //nolint:gocyclo
containers, err := s.getContainers(ctx, projectName, oneOffExclude, true)
if err != nil {
return err
@@ -86,6 +86,13 @@ func (s *composeService) restart(ctx context.Context, projectName string, option
eg, ctx := errgroup.WithContext(ctx)
for _, ctr := range containers.filter(isService(service)) {
eg.Go(func() error {
def := project.Services[service]
for _, hook := range def.PreStop {
err = s.runHook(ctx, ctr, def, hook, nil)
if err != nil {
return err
}
}
eventName := getContainerProgressName(ctr)
w.Event(progress.RestartingEvent(eventName))
timeout := utils.DurationSecondToInt(options.Timeout)
@@ -94,6 +101,12 @@ func (s *composeService) restart(ctx context.Context, projectName string, option
return err
}
w.Event(progress.StartedEvent(eventName))
for _, hook := range def.PostStart {
err = s.runHook(ctx, ctr, def, hook, nil)
if err != nil {
return err
}
}
return nil
})
}

View File

@@ -97,7 +97,9 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project,
Add(api.SlugLabel, slug).
Add(api.OneoffLabel, "True")
if err := s.ensureImagesExists(ctx, project, opts.Build, opts.QuietPull); err != nil { // all dependencies already checked, but might miss service img
// Only ensure image exists for the target service, dependencies were already handled by startDependencies
buildOpts := prepareBuildOptions(opts)
if err := s.ensureImagesExists(ctx, project, buildOpts, opts.QuietPull); err != nil { // all dependencies already checked, but might miss service img
return "", err
}
@@ -147,6 +149,16 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project,
return created.ID, err
}
func prepareBuildOptions(opts api.RunOptions) *api.BuildOptions {
if opts.Build == nil {
return nil
}
// Create a copy of build options and restrict to only the target service
buildOptsCopy := *opts.Build
buildOptsCopy.Services = []string{opts.Service}
return &buildOptsCopy
}
func applyRunOptions(project *types.Project, service *types.ServiceConfig, opts api.RunOptions) {
service.Tty = opts.Tty
service.StdinOpen = opts.Interactive

View File

@@ -104,7 +104,6 @@ func ReplaceEnvFile(in []byte, service string, i int, value string) ([]byte, err
} else {
return replace(in, envFile.Line, envFile.Column, value), nil
}
}
func getMapping(root *yaml.Node, key string) (*yaml.Node, error) {

View File

@@ -38,7 +38,7 @@ func (s *composeService) Wait(ctx context.Context, projectName string, options a
for _, ctr := range containers {
eg.Go(func() error {
var err error
resultC, errC := s.dockerCli.Client().ContainerWait(waitCtx, ctr.ID, "")
resultC, errC := s.apiClient().ContainerWait(waitCtx, ctr.ID, "")
select {
case result := <-resultC:

View File

@@ -0,0 +1,101 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package e2e
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"testing"
"gotest.tools/v3/assert"
"gotest.tools/v3/icmd"
)
// TestRunBuildOnce tests that services with pull_policy: build are only built once
// when using 'docker compose run', even when they are dependencies.
// This addresses a bug where dependencies were built twice: once in startDependencies
// and once in ensureImagesExists.
func TestRunBuildOnce(t *testing.T) {
c := NewParallelCLI(t)
t.Run("dependency with pull_policy build is built only once", func(t *testing.T) {
projectName := randomProjectName("build-once")
res := c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once.yaml", "down", "--rmi", "local", "--remove-orphans")
res.Assert(t, icmd.Success)
res = c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once.yaml", "run", "--build", "--rm", "curl")
res.Assert(t, icmd.Success)
// Count how many times nginx was built by looking for its unique RUN command output
nginxBuilds := strings.Count(res.Combined(), "Building nginx at")
// nginx should build exactly once, not twice
assert.Equal(t, nginxBuilds, 1, "nginx dependency should build once, but built %d times", nginxBuilds)
assert.Assert(t, strings.Contains(res.Combined(), "curl service"))
c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once.yaml", "down", "--remove-orphans")
})
t.Run("nested dependencies build only once each", func(t *testing.T) {
projectName := randomProjectName("build-nested")
res := c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-nested.yaml", "down", "--rmi", "local", "--remove-orphans")
res.Assert(t, icmd.Success)
res = c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-nested.yaml", "run", "--build", "--rm", "app")
res.Assert(t, icmd.Success)
output := res.Combined()
// Each service should build exactly once
dbBuilds := strings.Count(output, "DB built at")
apiBuilds := strings.Count(output, "API built at")
appBuilds := strings.Count(output, "App built at")
assert.Equal(t, dbBuilds, 1, "db should build once, built %d times", dbBuilds)
assert.Equal(t, apiBuilds, 1, "api should build once, built %d times", apiBuilds)
assert.Equal(t, appBuilds, 1, "app should build once, built %d times", appBuilds)
assert.Assert(t, strings.Contains(output, "App running"))
c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-nested.yaml", "down", "--remove-orphans")
})
t.Run("service with no dependencies builds once", func(t *testing.T) {
projectName := randomProjectName("build-simple")
res := c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-no-deps.yaml", "down", "--rmi", "local", "--remove-orphans")
res.Assert(t, icmd.Success)
res = c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-no-deps.yaml", "run", "--build", "--rm", "simple")
res.Assert(t, icmd.Success)
// Should build exactly once
simpleBuilds := strings.Count(res.Combined(), "Simple service built at")
assert.Equal(t, simpleBuilds, 1, "simple should build once, built %d times", simpleBuilds)
assert.Assert(t, strings.Contains(res.Combined(), "Simple service"))
c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-no-deps.yaml", "down", "--remove-orphans")
})
}
// randomProjectName generates a unique project name for parallel test execution
// Format: prefix-<8 random hex chars> (e.g., "build-once-3f4a9b2c")
func randomProjectName(prefix string) string {
b := make([]byte, 4) // 4 bytes = 8 hex chars
rand.Read(b) //nolint:errcheck
return fmt.Sprintf("%s-%s", prefix, hex.EncodeToString(b))
}

View File

@@ -0,0 +1,3 @@
services:
app:
env_file: test.env

View File

@@ -0,0 +1,5 @@
services:
app:
extends:
file: extends.yaml
service: test

View File

@@ -0,0 +1,3 @@
services:
test:
image: alpine

View File

@@ -0,0 +1 @@
HELLO=WORLD

View File

@@ -0,0 +1,32 @@
services:
# Database service with build
db:
pull_policy: build
build:
dockerfile_inline: |
FROM alpine
RUN echo "DB built at $(date)" > /db-build.txt
CMD sleep 3600
# API service that depends on db
api:
pull_policy: build
build:
dockerfile_inline: |
FROM alpine
RUN echo "API built at $(date)" > /api-build.txt
CMD sleep 3600
depends_on:
- db
# App service that depends on api (which depends on db)
app:
pull_policy: build
build:
dockerfile_inline: |
FROM alpine
RUN echo "App built at $(date)" > /app-build.txt
CMD echo "App running"
depends_on:
- api

View File

@@ -0,0 +1,10 @@
services:
# Simple service with no dependencies
simple:
pull_policy: build
build:
dockerfile_inline: |
FROM alpine
RUN echo "Simple service built at $(date)" > /build.txt
CMD echo "Simple service"

View File

@@ -0,0 +1,18 @@
services:
# Service with pull_policy: build to ensure it always rebuilds
# This is the key to testing the bug - without the fix, this would build twice
nginx:
pull_policy: build
build:
dockerfile_inline: |
FROM alpine
RUN echo "Building nginx at $(date)" > /build-time.txt
CMD sleep 3600
# Service that depends on nginx
curl:
image: alpine
depends_on:
- nginx
command: echo "curl service"

View File

@@ -17,6 +17,7 @@
package e2e
import (
"fmt"
"strings"
"testing"
@@ -173,3 +174,43 @@ FOO=bar`), out)
assert.Assert(t, strings.Contains(output, "Private Key\n\"\": -----BEGIN DSA PRIVATE KEY-----\nwxyz+ABC=\n-----END DSA PRIVATE KEY-----"), output)
})
}
func TestPublish(t *testing.T) {
c := NewParallelCLI(t)
const projectName = "compose-e2e-publish"
const registryName = projectName + "-registry"
c.RunDockerCmd(t, "run", "--name", registryName, "-P", "-d", "registry:3")
port := c.RunDockerCmd(t, "inspect", "--format", `{{ (index (index .NetworkSettings.Ports "5000/tcp") 0).HostPort }}`, registryName).Stdout()
registry := "localhost:" + strings.TrimSpace(port)
t.Cleanup(func() {
c.RunDockerCmd(t, "rm", "--force", registryName)
})
cmd := c.NewDockerComposeCmd(t, "-f", "./fixtures/publish/oci/compose.yaml", "-f", "./fixtures/publish/oci/compose-override.yaml",
"-p", projectName, "publish", "--with-env", "--yes", registry+"/test:test")
icmd.RunCmd(cmd, func(cmd *icmd.Cmd) {
cmd.Env = append(cmd.Env, "__TEST__INSECURE__REGISTRY__=true")
}).Assert(t, icmd.Expected{ExitCode: 0})
// docker exec -it compose-e2e-publish-registry tree /var/lib/registry/docker/registry/v2/
cmd = c.NewDockerComposeCmd(t, "--verbose", "--project-name=oci", "-f", fmt.Sprintf("oci://%s/test:test", registry), "config")
res := icmd.RunCmd(cmd, func(cmd *icmd.Cmd) {
cmd.Env = append(cmd.Env,
"XDG_CACHE_HOME="+t.TempDir(),
"__TEST__INSECURE__REGISTRY__=true")
})
res.Assert(t, icmd.Expected{ExitCode: 0})
assert.Equal(t, res.Stdout(), `name: oci
services:
app:
environment:
HELLO: WORLD
image: alpine
networks:
default: null
networks:
default:
name: oci_default
`)
}

View File

@@ -26,6 +26,7 @@ import (
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/compose-spec/compose-go/v2/cli"
"github.com/compose-spec/compose-go/v2/loader"
@@ -113,6 +114,9 @@ func (g gitRemoteLoader) Load(ctx context.Context, path string) (string, error)
g.known[path] = local
}
if ref.SubDir != "" {
if err := validateGitSubDir(local, ref.SubDir); err != nil {
return "", err
}
local = filepath.Join(local, ref.SubDir)
}
stat, err := os.Stat(local)
@@ -129,6 +133,41 @@ func (g gitRemoteLoader) Dir(path string) string {
return g.known[path]
}
// validateGitSubDir ensures a subdirectory path is contained within the base directory
// and doesn't escape via path traversal. Unlike validatePathInBase for OCI artifacts,
// this allows nested directories but prevents traversal outside the base.
func validateGitSubDir(base, subDir string) error {
cleanSubDir := filepath.Clean(subDir)
if filepath.IsAbs(cleanSubDir) {
return fmt.Errorf("git subdirectory must be relative, got: %s", subDir)
}
if cleanSubDir == ".." || strings.HasPrefix(cleanSubDir, "../") || strings.HasPrefix(cleanSubDir, "..\\") {
return fmt.Errorf("git subdirectory path traversal detected: %s", subDir)
}
if len(cleanSubDir) >= 2 && cleanSubDir[1] == ':' {
return fmt.Errorf("git subdirectory must be relative, got: %s", subDir)
}
targetPath := filepath.Join(base, cleanSubDir)
cleanBase := filepath.Clean(base)
cleanTarget := filepath.Clean(targetPath)
// Ensure the target starts with the base path
relPath, err := filepath.Rel(cleanBase, cleanTarget)
if err != nil {
return fmt.Errorf("invalid git subdirectory path: %w", err)
}
if relPath == ".." || strings.HasPrefix(relPath, "../") || strings.HasPrefix(relPath, "..\\") {
return fmt.Errorf("git subdirectory escapes base directory: %s", subDir)
}
return nil
}
func (g gitRemoteLoader) resolveGitRef(ctx context.Context, path string, ref *gitutil.GitRef) error {
if !commitSHA.MatchString(ref.Ref) {
cmd := exec.CommandContext(ctx, "git", "ls-remote", "--exit-code", ref.Remote, ref.Ref)

175
pkg/remote/git_test.go Normal file
View File

@@ -0,0 +1,175 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package remote
import (
"testing"
"gotest.tools/v3/assert"
)
func TestValidateGitSubDir(t *testing.T) {
base := "/tmp/cache/compose/abc123def456"
tests := []struct {
name string
subDir string
wantErr bool
}{
{
name: "valid simple directory",
subDir: "examples",
wantErr: false,
},
{
name: "valid nested directory",
subDir: "examples/nginx",
wantErr: false,
},
{
name: "valid deeply nested directory",
subDir: "examples/web/frontend/config",
wantErr: false,
},
{
name: "valid current directory",
subDir: ".",
wantErr: false,
},
{
name: "valid directory with redundant separators",
subDir: "examples//nginx",
wantErr: false,
},
{
name: "valid directory with dots in name",
subDir: "examples/nginx.conf.d",
wantErr: false,
},
{
name: "path traversal - parent directory",
subDir: "..",
wantErr: true,
},
{
name: "path traversal - multiple parent directories",
subDir: "../../../etc/passwd",
wantErr: true,
},
{
name: "path traversal - deeply nested escape",
subDir: "../../../../../../../tmp/pwned",
wantErr: true,
},
{
name: "path traversal - mixed with valid path",
subDir: "examples/../../etc/passwd",
wantErr: true,
},
{
name: "path traversal - at the end",
subDir: "examples/..",
wantErr: false, // This resolves to "." which is the current directory, safe
},
{
name: "path traversal - in the middle",
subDir: "examples/../../../etc/passwd",
wantErr: true,
},
{
name: "path traversal - windows style",
subDir: "..\\..\\..\\windows\\system32",
wantErr: true,
},
{
name: "absolute unix path",
subDir: "/etc/passwd",
wantErr: true,
},
{
name: "absolute windows path",
subDir: "C:\\windows\\system32\\config\\sam",
wantErr: true,
},
{
name: "absolute path with home directory",
subDir: "/home/user/.ssh/id_rsa",
wantErr: true,
},
{
name: "normalized path that would escape",
subDir: "./../../etc/passwd",
wantErr: true,
},
{
name: "directory name with three dots",
subDir: ".../config",
wantErr: false,
},
{
name: "directory name with four dots",
subDir: "..../config",
wantErr: false,
},
{
name: "directory name with five dots",
subDir: "...../etc/passwd",
wantErr: false, // ".....'' is a valid directory name, not path traversal
},
{
name: "directory name starting with two dots and letter",
subDir: "..foo/bar",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateGitSubDir(base, tt.subDir)
if (err != nil) != tt.wantErr {
t.Errorf("validateGitSubDir(%q, %q) error = %v, wantErr %v",
base, tt.subDir, err, tt.wantErr)
}
})
}
}
// TestValidateGitSubDirSecurityScenarios tests specific security scenarios
func TestValidateGitSubDirSecurityScenarios(t *testing.T) {
base := "/var/cache/docker-compose/git/1234567890abcdef"
// Test the exact vulnerability scenario from the issue
t.Run("CVE scenario - /tmp traversal", func(t *testing.T) {
maliciousPath := "../../../../../../../tmp/pwned"
err := validateGitSubDir(base, maliciousPath)
assert.ErrorContains(t, err, "path traversal")
})
// Test variations of the attack
t.Run("CVE scenario - /etc traversal", func(t *testing.T) {
maliciousPath := "../../../../../../../../etc/passwd"
err := validateGitSubDir(base, maliciousPath)
assert.ErrorContains(t, err, "path traversal")
})
// Test that legitimate nested paths still work
t.Run("legitimate nested path", func(t *testing.T) {
validPath := "examples/docker-compose/nginx/config"
err := validateGitSubDir(base, validPath)
assert.NilError(t, err)
})
}

View File

@@ -179,7 +179,7 @@ func (g ociRemoteLoader) Dir(path string) string {
return g.known[path]
}
func (g ociRemoteLoader) pullComposeFiles(ctx context.Context, local string, manifest spec.Manifest, ref reference.Named, resolver remotes.Resolver) error { //nolint:gocyclo
func (g ociRemoteLoader) pullComposeFiles(ctx context.Context, local string, manifest spec.Manifest, ref reference.Named, resolver remotes.Resolver) error {
err := os.MkdirAll(local, 0o700)
if err != nil {
return err
@@ -223,7 +223,7 @@ func writeComposeFile(layer spec.Descriptor, i int, local string, content []byte
return err
}
}
f, err := os.Create(filepath.Join(local, file))
f, err := os.OpenFile(filepath.Join(local, file), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o600)
if err != nil {
return err
}

View File

@@ -19,6 +19,9 @@ package remote
import (
"path/filepath"
"testing"
spec "github.com/opencontainers/image-spec/specs-go/v1"
"gotest.tools/v3/assert"
)
func TestValidatePathInBase(t *testing.T) {
@@ -84,11 +87,6 @@ func TestValidatePathInBase(t *testing.T) {
unsafePath: "..",
wantErr: true,
},
{
name: "current directory reference",
unsafePath: "./file.yaml",
wantErr: false, // ./ resolves to base dir
},
{
name: "mixed separators",
unsafePath: "config/sub\\file.yaml",
@@ -104,11 +102,6 @@ func TestValidatePathInBase(t *testing.T) {
unsafePath: "file-name_v1.2.3.yaml",
wantErr: false,
},
{
name: "single parent then back",
unsafePath: "../compose/file.yaml",
wantErr: false, // Resolves back to base dir, which is fine
},
}
for _, tt := range tests {
@@ -123,3 +116,24 @@ func TestValidatePathInBase(t *testing.T) {
})
}
}
func TestWriteComposeFileWithExtendsPathTraversal(t *testing.T) {
tmpDir := t.TempDir()
// Create a layer with com.docker.compose.extends=true and a path traversal attempt
layer := spec.Descriptor{
MediaType: "application/vnd.docker.compose.file.v1+yaml",
Digest: "sha256:test123",
Size: 100,
Annotations: map[string]string{
"com.docker.compose.extends": "true",
"com.docker.compose.file": "../other",
},
}
content := []byte("services:\n test:\n image: nginx\n")
// writeComposeFile should return an error due to path traversal
err := writeComposeFile(layer, 0, tmpDir, content)
assert.Error(t, err, "invalid OCI artifact")
}