1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-18 21:09:40 +02:00

fix(libstack): correctly load COMPOSE_* env vars [BE-11474] (#536)

This commit is contained in:
Devon Steenberg 2025-03-25 08:57:23 +13:00 committed by GitHub
parent 5d1cd670e9
commit 34235199dd
7 changed files with 952 additions and 87 deletions

1
go.mod
View file

@ -192,6 +192,7 @@ require (
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/miekg/pkcs11 v1.1.1 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect

2
go.sum
View file

@ -481,6 +481,8 @@ github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU=
github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=

View file

@ -1,8 +1,18 @@
package compose
type ComposeDeployer struct{}
import (
"github.com/docker/cli/cli/command"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/compose"
)
type ComposeDeployer struct {
createComposeServiceFn func(command.Cli) api.Service
}
// NewComposeDeployer creates a new compose deployer
func NewComposeDeployer() *ComposeDeployer {
return &ComposeDeployer{}
return &ComposeDeployer{
createComposeServiceFn: compose.NewComposeService,
}
}

View file

@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strconv"
@ -15,13 +14,14 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/libstack"
"github.com/compose-spec/compose-go/v2/dotenv"
"github.com/compose-spec/compose-go/v2/loader"
"github.com/compose-spec/compose-go/v2/cli"
"github.com/compose-spec/compose-go/v2/types"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/flags"
cmdcompose "github.com/docker/compose/v2/cmd/compose"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/compose"
"github.com/docker/compose/v2/pkg/utils"
"github.com/docker/docker/registry"
"github.com/rs/zerolog/log"
"github.com/sirupsen/logrus"
@ -75,66 +75,34 @@ func withCli(
return cliFn(ctx, cli)
}
func withComposeService(
func (c *ComposeDeployer) withComposeService(
ctx context.Context,
filePaths []string,
options libstack.Options,
composeFn func(api.Service, *types.Project) error,
) error {
return withCli(ctx, options, func(ctx context.Context, cli *command.DockerCli) error {
composeService := compose.NewComposeService(cli)
composeService := c.createComposeServiceFn(cli)
if len(filePaths) == 0 {
return composeFn(composeService, nil)
}
env, err := parseEnvironment(options, filePaths)
project, err := createProject(ctx, filePaths, options)
if err != nil {
return err
return fmt.Errorf("failed to create compose project: %w", err)
}
configDetails := types.ConfigDetails{
Environment: env,
WorkingDir: filepath.Dir(filePaths[0]),
}
if options.ProjectDir != "" {
// When relative paths are used in the compose file, the project directory is used as the base path
configDetails.WorkingDir = options.ProjectDir
}
for _, p := range filePaths {
configDetails.ConfigFiles = append(configDetails.ConfigFiles, types.ConfigFile{Filename: p})
}
project, err := loader.LoadWithContext(ctx, configDetails,
func(o *loader.Options) {
o.SkipResolveEnvironment = true
o.ResolvePaths = !slices.Contains(options.ConfigOptions, "--no-path-resolution")
if options.ProjectName != "" {
o.SetProjectName(options.ProjectName, true)
}
},
)
if err != nil {
return fmt.Errorf("failed to load the compose file: %w", err)
}
// Work around compose path handling
for i, service := range project.Services {
for j, envFile := range service.EnvFiles {
if !filepath.IsAbs(envFile.Path) {
project.Services[i].EnvFiles[j].Path = filepath.Join(configDetails.WorkingDir, envFile.Path)
}
parallel := 0
if v, ok := project.Environment[cmdcompose.ComposeParallelLimit]; ok {
i, err := strconv.Atoi(v)
if err != nil {
return fmt.Errorf("%s must be an integer (found: %q)", cmdcompose.ComposeParallelLimit, v)
}
parallel = i
}
// Set the services environment variables
if p, err := project.WithServicesEnvironmentResolved(true); err == nil {
project = p
} else {
return fmt.Errorf("failed to resolve services environment: %w", err)
if parallel > 0 {
composeService.MaxConcurrency(parallel)
}
return composeFn(composeService, project)
@ -143,7 +111,7 @@ func withComposeService(
// Deploy creates and starts containers
func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, options libstack.DeployOptions) error {
return withComposeService(ctx, filePaths, options.Options, func(composeService api.Service, project *types.Project) error {
return c.withComposeService(ctx, filePaths, options.Options, func(composeService api.Service, project *types.Project) error {
addServiceLabels(project, false, options.EdgeStackID)
project = project.WithoutUnnecessaryResources()
@ -154,6 +122,12 @@ func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, option
}
opts.Create.RemoveOrphans = options.RemoveOrphans
if removeOrphans, ok := project.Environment[cmdcompose.ComposeRemoveOrphans]; ok {
opts.Create.RemoveOrphans = utils.StringToBool(removeOrphans)
}
if ignoreOrphans, ok := project.Environment[cmdcompose.ComposeIgnoreOrphans]; ok {
opts.Create.IgnoreOrphans = utils.StringToBool(ignoreOrphans)
}
if options.AbortOnContainerExit {
opts.Start.OnExit = api.CascadeStop
@ -175,7 +149,7 @@ func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, option
// Run runs the given service just once, without considering dependencies
func (c *ComposeDeployer) Run(ctx context.Context, filePaths []string, serviceName string, options libstack.RunOptions) error {
return withComposeService(ctx, filePaths, options.Options, func(composeService api.Service, project *types.Project) error {
return c.withComposeService(ctx, filePaths, options.Options, func(composeService api.Service, project *types.Project) error {
addServiceLabels(project, true, 0)
for name, service := range project.Services {
@ -227,7 +201,7 @@ func (c *ComposeDeployer) Remove(ctx context.Context, projectName string, filePa
// Pull pulls images
func (c *ComposeDeployer) Pull(ctx context.Context, filePaths []string, options libstack.Options) error {
if err := withComposeService(ctx, filePaths, options, func(composeService api.Service, project *types.Project) error {
if err := c.withComposeService(ctx, filePaths, options, func(composeService api.Service, project *types.Project) error {
return composeService.Pull(ctx, project, api.PullOptions{})
}); err != nil {
return fmt.Errorf("compose pull operation failed: %w", err)
@ -240,7 +214,7 @@ func (c *ComposeDeployer) Pull(ctx context.Context, filePaths []string, options
// Validate validates stack file
func (c *ComposeDeployer) Validate(ctx context.Context, filePaths []string, options libstack.Options) error {
return withComposeService(ctx, filePaths, options, func(composeService api.Service, project *types.Project) error {
return c.withComposeService(ctx, filePaths, options, func(composeService api.Service, project *types.Project) error {
return nil
})
}
@ -249,7 +223,7 @@ func (c *ComposeDeployer) Validate(ctx context.Context, filePaths []string, opti
func (c *ComposeDeployer) Config(ctx context.Context, filePaths []string, options libstack.Options) ([]byte, error) {
var payload []byte
if err := withComposeService(ctx, filePaths, options, func(composeService api.Service, project *types.Project) error {
if err := c.withComposeService(ctx, filePaths, options, func(composeService api.Service, project *types.Project) error {
var err error
payload, err = project.MarshalYAML()
if err != nil {
@ -267,7 +241,7 @@ func (c *ComposeDeployer) Config(ctx context.Context, filePaths []string, option
func (c *ComposeDeployer) GetExistingEdgeStacks(ctx context.Context) ([]libstack.EdgeStack, error) {
m := make(map[int]libstack.EdgeStack)
if err := withComposeService(ctx, nil, libstack.Options{}, func(composeService api.Service, project *types.Project) error {
if err := c.withComposeService(ctx, nil, libstack.Options{}, func(composeService api.Service, project *types.Project) error {
stacks, err := composeService.List(ctx, api.ListOptions{
All: true,
})
@ -332,43 +306,71 @@ func addServiceLabels(project *types.Project, oneOff bool, edgeStackID portainer
}
}
func parseEnvironment(options libstack.Options, filePaths []string) (map[string]string, error) {
env := make(map[string]string)
for _, envLine := range options.Env {
e, err := dotenv.UnmarshalWithLookup(envLine, nil)
if err != nil {
return nil, fmt.Errorf("unable to parse environment variables: %w", err)
}
maps.Copy(env, e)
func createProject(ctx context.Context, configFilepaths []string, options libstack.Options) (*types.Project, error) {
var workingDir string
if len(configFilepaths) > 0 {
workingDir = filepath.Dir(configFilepaths[0])
}
if options.EnvFilePath == "" {
if len(filePaths) == 0 {
return env, nil
}
defaultDotEnv := filepath.Join(filepath.Dir(filePaths[0]), ".env")
s, err := os.Stat(defaultDotEnv)
if os.IsNotExist(err) {
return env, nil
}
if err != nil {
return env, err
}
if s.IsDir() {
return env, nil
}
options.EnvFilePath = defaultDotEnv
if options.WorkingDir != "" {
workingDir = options.WorkingDir
}
e, err := dotenv.GetEnvFromFile(make(map[string]string), []string{options.EnvFilePath})
if options.ProjectDir != "" {
// When relative paths are used in the compose file, the project directory is used as the base path
workingDir = options.ProjectDir
}
var envFiles []string
if options.EnvFilePath != "" {
envFiles = append(envFiles, options.EnvFilePath)
}
projectOptions, err := cli.NewProjectOptions(configFilepaths,
cli.WithWorkingDirectory(workingDir),
cli.WithName(options.ProjectName),
cli.WithoutEnvironmentResolution,
cli.WithResolvedPaths(!slices.Contains(options.ConfigOptions, "--no-path-resolution")),
cli.WithEnv(options.Env),
cli.WithEnvFiles(envFiles...),
func(o *cli.ProjectOptions) error {
if len(o.EnvFiles) > 0 {
return nil
}
if fs, ok := o.Environment[cmdcompose.ComposeEnvFiles]; ok {
o.EnvFiles = strings.Split(fs, ",")
}
return nil
},
cli.WithDotEnv,
cli.WithDefaultProfiles(),
cli.WithConfigFileEnv,
)
if err != nil {
return nil, fmt.Errorf("unable to get the environment from the env file: %w", err)
return nil, fmt.Errorf("failed to load the compose file options : %w", err)
}
maps.Copy(env, e)
project, err := projectOptions.LoadProject(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load the compose file : %w", err)
}
return env, nil
// Work around compose path handling
for i, service := range project.Services {
for j, envFile := range service.EnvFiles {
if !filepath.IsAbs(envFile.Path) {
project.Services[i].EnvFiles[j].Path = filepath.Join(workingDir, envFile.Path)
}
}
}
// Set the services environment variables
if p, err := project.WithServicesEnvironmentResolved(true); err == nil {
project = p
} else {
return nil, fmt.Errorf("failed to resolve services environment: %w", err)
}
return project, nil
}

View file

@ -2,14 +2,23 @@ package compose
import (
"context"
"errors"
"log"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/compose-spec/compose-go/v2/consts"
"github.com/compose-spec/compose-go/v2/types"
"github.com/docker/cli/cli/command"
cmdcompose "github.com/docker/compose/v2/cmd/compose"
"github.com/docker/compose/v2/pkg/api"
"github.com/google/go-cmp/cmp"
"github.com/portainer/portainer/pkg/libstack"
zerolog "github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
)
@ -349,3 +358,735 @@ networks:
})
}
}
func Test_DeployWithRemoveOrphans(t *testing.T) {
const projectName = "compose_remove_orphans_test"
const composeFileContent = `services:
service-1:
image: alpine:latest
service-2:
image: alpine:latest`
const modifiedFileContent = `services:
service-2:
image: alpine:latest`
service1ContainerName := projectName + "-service-1"
service2ContainerName := projectName + "-service-2"
w := NewComposeDeployer()
dir := t.TempDir()
composeFilepath := createFile(t, dir, "docker-compose.yml", composeFileContent)
modifiedComposeFilepath := createFile(t, dir, "docker-compose-modified.yml", modifiedFileContent)
filepaths := []string{composeFilepath}
modifiedFilepaths := []string{modifiedComposeFilepath}
ctx := context.Background()
testCases := []struct {
name string
options libstack.DeployOptions
}{
{
name: "Remove Orphans in env",
options: libstack.DeployOptions{
Options: libstack.Options{
ProjectName: projectName,
Env: []string{cmdcompose.ComposeRemoveOrphans + "=true"},
},
},
},
{
name: "Remove Orphans in options",
options: libstack.DeployOptions{
Options: libstack.Options{
ProjectName: projectName,
},
RemoveOrphans: true,
},
},
{
name: "Remove Orphans in options and env",
options: libstack.DeployOptions{
Options: libstack.Options{
ProjectName: projectName,
Env: []string{cmdcompose.ComposeRemoveOrphans + "=true"},
},
RemoveOrphans: false,
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
options := tc.options.Options
err := w.Validate(ctx, filepaths, options)
require.NoError(t, err)
err = w.Pull(ctx, filepaths, options)
require.NoError(t, err)
require.False(t, containerExists(service1ContainerName))
require.False(t, containerExists(service2ContainerName))
err = w.Deploy(ctx, filepaths, tc.options)
require.NoError(t, err)
defer func() {
err = w.Remove(ctx, projectName, filepaths, libstack.RemoveOptions{})
require.NoError(t, err)
require.False(t, containerExists(service1ContainerName))
require.False(t, containerExists(service2ContainerName))
}()
require.True(t, containerExists(service1ContainerName))
require.True(t, containerExists(service2ContainerName))
waitResult := w.WaitForStatus(ctx, projectName, libstack.StatusCompleted)
require.Empty(t, waitResult.ErrorMsg)
require.Equal(t, libstack.StatusCompleted, waitResult.Status)
err = w.Validate(ctx, modifiedFilepaths, options)
require.NoError(t, err)
err = w.Pull(ctx, modifiedFilepaths, options)
require.NoError(t, err)
require.True(t, containerExists(service1ContainerName))
require.True(t, containerExists(service2ContainerName))
err = w.Deploy(ctx, modifiedFilepaths, tc.options)
require.NoError(t, err)
require.False(t, containerExists(service1ContainerName))
require.True(t, containerExists(service2ContainerName))
waitResult = w.WaitForStatus(ctx, projectName, libstack.StatusCompleted)
require.Empty(t, waitResult.ErrorMsg)
require.Equal(t, libstack.StatusCompleted, waitResult.Status)
})
}
}
func Test_DeployWithIgnoreOrphans(t *testing.T) {
var logOutput strings.Builder
oldLogger := zerolog.Logger
zerolog.Logger = zerolog.Output(&logOutput)
defer func() {
zerolog.Logger = oldLogger
}()
const projectName = "compose_ignore_orphans_test"
const composeFileContent = `services:
service-1:
image: alpine:latest
service-2:
image: alpine:latest`
const modifiedFileContent = `services:
service-2:
image: alpine:latest`
service1ContainerName := projectName + "-service-1"
service2ContainerName := projectName + "-service-2"
w := NewComposeDeployer()
dir := t.TempDir()
composeFilepath := createFile(t, dir, "docker-compose.yml", composeFileContent)
modifiedComposeFilepath := createFile(t, dir, "docker-compose-modified.yml", modifiedFileContent)
filepaths := []string{composeFilepath}
modifiedFilepaths := []string{modifiedComposeFilepath}
options := libstack.Options{
ProjectName: projectName,
Env: []string{cmdcompose.ComposeIgnoreOrphans + "=true"},
}
ctx := context.Background()
err := w.Validate(ctx, filepaths, options)
require.NoError(t, err)
err = w.Pull(ctx, filepaths, options)
require.NoError(t, err)
require.False(t, containerExists(service1ContainerName))
require.False(t, containerExists(service2ContainerName))
err = w.Deploy(ctx, filepaths, libstack.DeployOptions{Options: options})
require.NoError(t, err)
defer func() {
err = w.Remove(ctx, projectName, filepaths, libstack.RemoveOptions{})
require.NoError(t, err)
require.False(t, containerExists(service1ContainerName))
require.False(t, containerExists(service2ContainerName))
}()
require.True(t, containerExists(service1ContainerName))
require.True(t, containerExists(service2ContainerName))
waitResult := w.WaitForStatus(ctx, projectName, libstack.StatusCompleted)
require.Empty(t, waitResult.ErrorMsg)
require.Equal(t, libstack.StatusCompleted, waitResult.Status)
err = w.Validate(ctx, modifiedFilepaths, options)
require.NoError(t, err)
err = w.Pull(ctx, modifiedFilepaths, options)
require.NoError(t, err)
require.True(t, containerExists(service1ContainerName))
require.True(t, containerExists(service2ContainerName))
err = w.Deploy(ctx, modifiedFilepaths, libstack.DeployOptions{Options: options})
require.NoError(t, err)
require.True(t, containerExists(service1ContainerName))
require.True(t, containerExists(service2ContainerName))
waitResult = w.WaitForStatus(ctx, projectName, libstack.StatusCompleted)
require.Empty(t, waitResult.ErrorMsg)
require.Equal(t, libstack.StatusCompleted, waitResult.Status)
logString := logOutput.String()
require.False(t, strings.Contains(logString, "Found orphan containers ([compose_ignore_orphans_test-service-1-1])"))
}
func Test_MaxConcurrency(t *testing.T) {
const projectName = "compose_max_concurrency_test"
const composeFileContent = `services:
service-1:
image: alpine:latest`
w := ComposeDeployer{
createComposeServiceFn: createMockComposeService,
}
dir := t.TempDir()
composeFilepath := createFile(t, dir, "docker-compose.yml", composeFileContent)
expectedMaxConcurrency := 4
filepaths := []string{composeFilepath}
options := libstack.Options{
ProjectName: projectName,
Env: []string{cmdcompose.ComposeParallelLimit + "=" + strconv.Itoa(expectedMaxConcurrency)},
}
ctx := context.Background()
err := w.Validate(ctx, filepaths, options)
require.NoError(t, err)
w.withComposeService(ctx, filepaths, options, func(service api.Service, _ *types.Project) error {
if mockS, ok := service.(*mockComposeService); ok {
require.Equal(t, mockS.maxConcurrency, expectedMaxConcurrency)
} else {
t.Fatalf("Expected mockComposeService but got %T", service)
}
return nil
})
}
func Test_createProject(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
projectName := "create-project-test"
defer func() {
err := os.RemoveAll(dir)
if err != nil {
t.Fatalf("Failed to remove temp dir: %v", err)
}
}()
createTestLibstackOptions := func(workingDir, projectName string, env []string, envFilepath string) libstack.Options {
return libstack.Options{
WorkingDir: workingDir,
ProjectName: projectName,
Env: env,
EnvFilePath: envFilepath,
}
}
testSimpleComposeConfig := `services:
nginx:
container_name: nginx
image: nginx:latest`
expectedSimpleComposeProject := func(workingDirectory string, envOverrides map[string]string) *types.Project {
env := types.Mapping{consts.ComposeProjectName: "create-project-test"}
env = env.Merge(envOverrides)
if workingDirectory == "" {
workingDirectory = dir
}
if !filepath.IsAbs(workingDirectory) {
absWorkingDir, err := filepath.Abs(workingDirectory)
if err != nil {
t.Fatalf("Failed to get absolute path of working directory (%s): %v", workingDirectory, err)
}
workingDirectory = absWorkingDir
}
return &types.Project{
Name: projectName,
WorkingDir: workingDirectory,
Services: types.Services{
"nginx": {
Name: "nginx",
ContainerName: "nginx",
Environment: types.MappingWithEquals{},
Image: "nginx:latest",
Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
},
},
Networks: types.Networks{"default": {Name: "create-project-test_default"}},
ComposeFiles: []string{
dir + "/docker-compose.yml",
},
Environment: env,
DisabledServices: types.Services{},
Profiles: []string{""},
}
}
testComposeProfilesConfig := `services:
nginx:
container_name: nginx
image: nginx:latest
profiles: ['web1']
apache:
container_name: apache
image: httpd:latest
profiles: ['web2']`
expectedComposeProfilesProject := func(envOverrides map[string]string) *types.Project {
env := types.Mapping{consts.ComposeProfiles: "web1", consts.ComposeProjectName: "create-project-test"}
env = env.Merge(envOverrides)
return &types.Project{
Name: projectName,
WorkingDir: dir,
Services: types.Services{
"nginx": {
Name: "nginx",
Profiles: []string{"web1"},
ContainerName: "nginx",
Environment: types.MappingWithEquals{},
Image: "nginx:latest",
Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
},
},
Networks: types.Networks{"default": {Name: "create-project-test_default"}},
ComposeFiles: []string{
dir + "/docker-compose.yml",
},
Environment: env,
DisabledServices: types.Services{
"apache": {
Name: "apache",
Profiles: []string{"web2"},
ContainerName: "apache",
Environment: nil,
Image: "httpd:latest",
Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
},
},
Profiles: []string{"web1"},
}
}
testcases := []struct {
name string
filesToCreate map[string]string
configFilepaths []string
options libstack.Options
expectedProject *types.Project
}{
{
name: "Compose profiles in env",
filesToCreate: map[string]string{
"docker-compose.yml": testComposeProfilesConfig,
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: createTestLibstackOptions(dir, projectName, []string{consts.ComposeProfiles + "=web1"}, ""),
expectedProject: expectedComposeProfilesProject(nil),
},
{
name: "Compose profiles in env file",
filesToCreate: map[string]string{
"docker-compose.yml": testComposeProfilesConfig,
"stack.env": consts.ComposeProfiles + "=web1",
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: createTestLibstackOptions(dir, projectName, nil, dir+"/stack.env"),
expectedProject: expectedComposeProfilesProject(nil),
},
{
name: "Compose profiles in env file in COMPOSE_ENV_FILES",
filesToCreate: map[string]string{
"docker-compose.yml": testComposeProfilesConfig,
"stack.env": consts.ComposeProfiles + "=web1",
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: createTestLibstackOptions(dir, projectName, []string{cmdcompose.ComposeEnvFiles + "=" + dir + "/stack.env"}, ""),
expectedProject: expectedComposeProfilesProject(map[string]string{
cmdcompose.ComposeEnvFiles: dir + "/stack.env",
}),
},
{
name: "Compose profiles in both env and env file",
filesToCreate: map[string]string{
"docker-compose.yml": testComposeProfilesConfig,
"stack.env": consts.ComposeProfiles + "=web2",
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: createTestLibstackOptions(dir, projectName, []string{consts.ComposeProfiles + "=web1"}, dir+"/stack.env"),
expectedProject: expectedComposeProfilesProject(nil),
},
{
name: "Compose project name in both options and env",
filesToCreate: map[string]string{
"docker-compose.yml": testSimpleComposeConfig,
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: createTestLibstackOptions(dir, projectName, []string{consts.ComposeProjectName + "=totally_different_name"}, ""),
expectedProject: expectedSimpleComposeProject("", nil),
},
{
name: "Compose project name in only env",
filesToCreate: map[string]string{
"docker-compose.yml": testSimpleComposeConfig,
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: createTestLibstackOptions(dir, "", []string{consts.ComposeProjectName + "=totally_different_name"}, ""),
expectedProject: &types.Project{
Name: "totally_different_name",
WorkingDir: dir,
Services: types.Services{
"nginx": {
Name: "nginx",
ContainerName: "nginx",
Environment: types.MappingWithEquals{},
Image: "nginx:latest",
Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
},
},
Networks: types.Networks{"default": {Name: "totally_different_name_default"}},
ComposeFiles: []string{
dir + "/docker-compose.yml",
},
Environment: types.Mapping{consts.ComposeProjectName: "totally_different_name"},
DisabledServices: types.Services{},
Profiles: []string{""},
},
},
{
name: "Compose files in env",
filesToCreate: map[string]string{
"docker-compose.yml": testSimpleComposeConfig,
},
configFilepaths: nil,
options: createTestLibstackOptions(dir, projectName, []string{consts.ComposeFilePath + "=" + dir + "/docker-compose.yml"}, ""),
expectedProject: expectedSimpleComposeProject("", map[string]string{
consts.ComposeFilePath: dir + "/docker-compose.yml",
}),
},
{
name: "Compose files in both options and env",
filesToCreate: map[string]string{
"docker-compose.yml": testSimpleComposeConfig,
"profiles-docker-compose.yml": testComposeProfilesConfig,
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: createTestLibstackOptions(dir, projectName, []string{consts.ComposeFilePath + "=" + dir + "/profiles-docker-compose.yml"}, ""),
expectedProject: expectedSimpleComposeProject("", map[string]string{
consts.ComposeFilePath: dir + "/profiles-docker-compose.yml",
}),
},
{
name: "Multiple Compose files in options",
filesToCreate: map[string]string{
"docker-compose-0.yml": `services:
nginx:
container_name: nginx
image: nginx:latest`,
"docker-compose-1.yml": `services:
apache:
container_name: apache
image: httpd:latest`,
},
configFilepaths: []string{dir + "/docker-compose-0.yml", dir + "/docker-compose-1.yml"},
options: createTestLibstackOptions(dir, projectName, []string{}, ""),
expectedProject: &types.Project{
Name: projectName,
WorkingDir: dir,
Services: types.Services{
"nginx": {
Name: "nginx",
ContainerName: "nginx",
Environment: types.MappingWithEquals{},
Image: "nginx:latest",
Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
},
"apache": {
Name: "apache",
ContainerName: "apache",
Environment: types.MappingWithEquals{},
Image: "httpd:latest",
Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
},
},
Networks: types.Networks{"default": {Name: "create-project-test_default"}},
ComposeFiles: []string{
dir + "/docker-compose-0.yml",
dir + "/docker-compose-1.yml",
},
Environment: types.Mapping{consts.ComposeProjectName: "create-project-test"},
DisabledServices: types.Services{},
Profiles: []string{""},
},
},
{
name: "Multiple Compose files in env",
filesToCreate: map[string]string{
"docker-compose-0.yml": `services:
nginx:
container_name: nginx
image: nginx:latest`,
"docker-compose-1.yml": `services:
apache:
container_name: apache
image: httpd:latest`,
},
configFilepaths: nil,
options: createTestLibstackOptions(dir, projectName, []string{consts.ComposeFilePath + "=" + dir + "/docker-compose-0.yml:" + dir + "/docker-compose-1.yml"}, ""),
expectedProject: &types.Project{
Name: projectName,
WorkingDir: dir,
Services: types.Services{
"nginx": {
Name: "nginx",
ContainerName: "nginx",
Environment: types.MappingWithEquals{},
Image: "nginx:latest",
Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
},
"apache": {
Name: "apache",
ContainerName: "apache",
Environment: types.MappingWithEquals{},
Image: "httpd:latest",
Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
},
},
Networks: types.Networks{"default": {Name: "create-project-test_default"}},
ComposeFiles: []string{
dir + "/docker-compose-0.yml",
dir + "/docker-compose-1.yml",
},
Environment: types.Mapping{consts.ComposeProjectName: "create-project-test", consts.ComposeFilePath: dir + "/docker-compose-0.yml:" + dir + "/docker-compose-1.yml"},
DisabledServices: types.Services{},
Profiles: []string{""},
},
},
{
name: "Multiple Compose files in env with COMPOSE_PATH_SEPARATOR",
filesToCreate: map[string]string{
"docker-compose-0.yml": `services:
nginx:
container_name: nginx
image: nginx:latest`,
"docker-compose-1.yml": `services:
apache:
container_name: apache
image: httpd:latest`,
},
configFilepaths: nil,
options: createTestLibstackOptions(dir, projectName, []string{consts.ComposePathSeparator + "=|", consts.ComposeFilePath + "=" + dir + "/docker-compose-0.yml|" + dir + "/docker-compose-1.yml"}, ""),
expectedProject: &types.Project{
Name: projectName,
WorkingDir: dir,
Services: types.Services{
"nginx": {
Name: "nginx",
ContainerName: "nginx",
Environment: types.MappingWithEquals{},
Image: "nginx:latest",
Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
},
"apache": {
Name: "apache",
ContainerName: "apache",
Environment: types.MappingWithEquals{},
Image: "httpd:latest",
Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
},
},
Networks: types.Networks{"default": {Name: "create-project-test_default"}},
ComposeFiles: []string{
dir + "/docker-compose-0.yml",
dir + "/docker-compose-1.yml",
},
Environment: types.Mapping{consts.ComposeProjectName: "create-project-test", consts.ComposePathSeparator: "|", consts.ComposeFilePath: dir + "/docker-compose-0.yml|" + dir + "/docker-compose-1.yml"},
DisabledServices: types.Services{},
Profiles: []string{""},
},
},
{
name: "compose ignore orphans",
filesToCreate: map[string]string{
"docker-compose.yml": testSimpleComposeConfig,
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: createTestLibstackOptions(dir, projectName, []string{cmdcompose.ComposeIgnoreOrphans + "=true"}, ""),
expectedProject: expectedSimpleComposeProject("", map[string]string{
cmdcompose.ComposeIgnoreOrphans: "true",
}),
},
{
name: "compose remove orphans",
filesToCreate: map[string]string{
"docker-compose.yml": testSimpleComposeConfig,
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: createTestLibstackOptions(dir, projectName, []string{cmdcompose.ComposeRemoveOrphans + "=true"}, ""),
expectedProject: expectedSimpleComposeProject("", map[string]string{
cmdcompose.ComposeRemoveOrphans: "true",
}),
},
{
name: "compose parallel limit",
filesToCreate: map[string]string{
"docker-compose.yml": testSimpleComposeConfig,
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: createTestLibstackOptions(dir, projectName, []string{cmdcompose.ComposeParallelLimit + "=true"}, ""),
expectedProject: expectedSimpleComposeProject("", map[string]string{
cmdcompose.ComposeParallelLimit: "true",
}),
},
{
name: "Absolute Working Directory",
filesToCreate: map[string]string{
"docker-compose.yml": testSimpleComposeConfig,
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: libstack.Options{
WorkingDir: "/something-totally-different",
ProjectName: projectName,
},
expectedProject: expectedSimpleComposeProject("/something-totally-different", nil),
},
{
name: "Relative Working Directory",
filesToCreate: map[string]string{
"docker-compose.yml": testSimpleComposeConfig,
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: libstack.Options{
WorkingDir: "something-totally-different",
ProjectName: projectName,
},
expectedProject: expectedSimpleComposeProject("something-totally-different", nil),
},
{
name: "Absolute Project Directory",
filesToCreate: map[string]string{
"docker-compose.yml": testSimpleComposeConfig,
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: libstack.Options{
ProjectDir: "/something-totally-different",
ProjectName: projectName,
},
expectedProject: expectedSimpleComposeProject("/something-totally-different", nil),
},
{
name: "Relative Project Directory",
filesToCreate: map[string]string{
"docker-compose.yml": testSimpleComposeConfig,
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: libstack.Options{
ProjectDir: "something-totally-different",
ProjectName: projectName,
},
expectedProject: expectedSimpleComposeProject("something-totally-different", nil),
},
{
name: "Absolute Project and Working Directory set",
filesToCreate: map[string]string{
"docker-compose.yml": testSimpleComposeConfig,
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: libstack.Options{
WorkingDir: "/working-dir",
ProjectDir: "/project-dir",
ProjectName: projectName,
},
expectedProject: expectedSimpleComposeProject("/project-dir", nil),
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
createdFiles := make([]string, 0, len(tc.filesToCreate))
for f, fc := range tc.filesToCreate {
createdFiles = append(createdFiles, createFile(t, dir, f, fc))
}
defer func() {
var errs []error
for _, f := range createdFiles {
errs = append(errs, os.Remove(f))
}
err := errors.Join(errs...)
if err != nil {
t.Fatalf("Failed to remove config files: %v", err)
}
}()
gotProject, err := createProject(ctx, tc.configFilepaths, tc.options)
if err != nil {
t.Fatalf("Failed to create new project: %v", err)
}
if diff := cmp.Diff(gotProject, tc.expectedProject); diff != "" {
t.Fatalf("Projects are different:\n%s", diff)
}
})
}
}
func createMockComposeService(dockerCli command.Cli) api.Service {
return &mockComposeService{}
}
type mockComposeService struct {
api.Service
maxConcurrency int
}
func (s *mockComposeService) MaxConcurrency(parallel int) {
s.maxConcurrency = parallel
}

View file

@ -0,0 +1,109 @@
package compose
import (
"context"
"errors"
"os"
"testing"
"github.com/compose-spec/compose-go/v2/types"
"github.com/google/go-cmp/cmp"
"github.com/portainer/portainer/pkg/libstack"
)
func Test_createProject_win(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
projectName := "create-project-test"
defer func() {
err := os.RemoveAll(dir)
if err != nil {
t.Fatalf("Failed to remove temp dir: %v", err)
}
}()
testcases := []struct {
name string
createFilesFn func() []string
configFilepaths []string
options libstack.Options
expectedProject *types.Project
}{
{
name: "Convert windows paths",
createFilesFn: func() []string {
var filepaths []string
filepaths = append(filepaths, createFile(t, dir, "docker-compose.yml", `services:
nginx:
container_name: nginx
image: nginx:latest
volumes:
- "C:\\Users\\Joey\\Desktop\\backend:/var/www/html"`))
return filepaths
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: libstack.Options{
WorkingDir: dir,
ProjectName: projectName,
Env: []string{"COMPOSE_CONVERT_WINDOWS_PATHS=true"},
},
expectedProject: &types.Project{
Name: projectName,
WorkingDir: dir,
Services: types.Services{
"nginx": {
Name: "nginx",
ContainerName: "nginx",
Environment: types.MappingWithEquals{},
Image: "nginx:latest",
Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
Volumes: []types.ServiceVolumeConfig{
{
Type: "bind",
Source: "/c/Users/Joey/Desktop/backend",
Target: "/var/www/html",
ReadOnly: false,
Bind: &types.ServiceVolumeBind{CreateHostPath: true},
},
},
},
},
Networks: types.Networks{"default": {Name: "create-project-test_default"}},
ComposeFiles: []string{
dir + "/docker-compose.yml",
},
Environment: types.Mapping{"COMPOSE_PROJECT_NAME": "create-project-test", "COMPOSE_CONVERT_WINDOWS_PATHS": "true"},
DisabledServices: types.Services{},
Profiles: []string{""},
},
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
createdFiles := tc.createFilesFn()
defer func() {
var errs []error
for _, f := range createdFiles {
errs = append(errs, os.Remove(f))
}
err := errors.Join(errs...)
if err != nil {
t.Fatalf("Failed to remove config files: %v", err)
}
}()
gotProject, err := createProject(ctx, tc.configFilepaths, tc.options)
if err != nil {
t.Fatalf("Failed to create new project: %v", err)
}
if diff := cmp.Diff(gotProject, tc.expectedProject); diff != "" {
t.Fatalf("Projects are different:\n%s", diff)
}
})
}
}

View file

@ -125,7 +125,7 @@ func (c *ComposeDeployer) WaitForStatus(ctx context.Context, name string, status
var containerSummaries []api.ContainerSummary
if err := withComposeService(ctx, nil, libstack.Options{ProjectName: name}, func(composeService api.Service, project *types.Project) error {
if err := c.withComposeService(ctx, nil, libstack.Options{ProjectName: name}, func(composeService api.Service, project *types.Project) error {
var err error
psCtx, cancelFunc := context.WithTimeout(context.Background(), time.Minute)