diff --git a/go.mod b/go.mod index 2c93a403a..4a28e80a1 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 8aab3b540..8fbb68613 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/libstack/compose/compose.go b/pkg/libstack/compose/compose.go index 3cadae4ba..6e859a93f 100644 --- a/pkg/libstack/compose/compose.go +++ b/pkg/libstack/compose/compose.go @@ -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, + } } diff --git a/pkg/libstack/compose/composeplugin.go b/pkg/libstack/compose/composeplugin.go index 8ebc12062..17126cc21 100644 --- a/pkg/libstack/compose/composeplugin.go +++ b/pkg/libstack/compose/composeplugin.go @@ -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 } diff --git a/pkg/libstack/compose/composeplugin_test.go b/pkg/libstack/compose/composeplugin_test.go index dd423b700..df71c5d36 100644 --- a/pkg/libstack/compose/composeplugin_test.go +++ b/pkg/libstack/compose/composeplugin_test.go @@ -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 +} diff --git a/pkg/libstack/compose/composeplugin_windows_test.go b/pkg/libstack/compose/composeplugin_windows_test.go new file mode 100644 index 000000000..c6f3f196a --- /dev/null +++ b/pkg/libstack/compose/composeplugin_windows_test.go @@ -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) + } + }) + } +} diff --git a/pkg/libstack/compose/status.go b/pkg/libstack/compose/status.go index bbbe2264b..b6ad44ebf 100644 --- a/pkg/libstack/compose/status.go +++ b/pkg/libstack/compose/status.go @@ -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)