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" ) func Test_UpAndDown(t *testing.T) { const projectName = "composetest" const composeFileContent = `version: "3.9" services: busybox: image: "alpine:3.7" container_name: "composetest_container_one"` const overrideComposeFileContent = `version: "3.9" services: busybox: image: "alpine:latest" container_name: "composetest_container_two"` composeContainerName := projectName + "_container_two" w := NewComposeDeployer() dir := t.TempDir() filePathOriginal := createFile(t, dir, "docker-compose.yml", composeFileContent) filePathOverride := createFile(t, dir, "docker-compose-override.yml", overrideComposeFileContent) filePaths := []string{filePathOriginal, filePathOverride} ctx := context.Background() err := w.Validate(ctx, filePaths, libstack.Options{ProjectName: projectName}) require.NoError(t, err) err = w.Pull(ctx, filePaths, libstack.Options{ProjectName: projectName}) require.NoError(t, err) require.False(t, containerExists(composeContainerName)) err = w.Deploy(ctx, filePaths, libstack.DeployOptions{ Options: libstack.Options{ ProjectName: projectName, }, }) require.NoError(t, err) require.True(t, containerExists(composeContainerName)) waitResult := w.WaitForStatus(ctx, projectName, libstack.StatusCompleted) require.Empty(t, waitResult.ErrorMsg) require.Equal(t, libstack.StatusCompleted, waitResult.Status) err = w.Remove(ctx, projectName, filePaths, libstack.RemoveOptions{}) require.NoError(t, err) require.False(t, containerExists(composeContainerName)) } func TestRun(t *testing.T) { w := NewComposeDeployer() filePath := createFile(t, t.TempDir(), "docker-compose.yml", ` services: updater: image: alpine `) filePaths := []string{filePath} serviceName := "updater" err := w.Run(context.Background(), filePaths, serviceName, libstack.RunOptions{ Remove: true, Options: libstack.Options{ ProjectName: "project_name", }, }) require.NoError(t, err) } func createFile(t *testing.T, dir, fileName, content string) string { filePath := filepath.Join(dir, fileName) err := os.WriteFile(filePath, []byte(content), 0o644) require.NoError(t, err) return filePath } func containerExists(containerName string) bool { cmd := exec.Command("docker", "ps", "-a", "-f", "name="+containerName) out, err := cmd.Output() if err != nil { log.Fatalf("failed to list containers: %s", err) } return strings.Contains(string(out), containerName) } func Test_Validate(t *testing.T) { invalidComposeFileContent := `invalid-file-content` w := NewComposeDeployer() dir := t.TempDir() filePathOriginal := createFile(t, dir, "docker-compose.yml", invalidComposeFileContent) filePaths := []string{filePathOriginal} projectName := "plugintest" ctx := context.Background() err := w.Validate(ctx, filePaths, libstack.Options{ProjectName: projectName}) require.Error(t, err) } func Test_Config(t *testing.T) { ctx := context.Background() dir := t.TempDir() projectName := "configtest" defer os.RemoveAll(dir) testCases := []struct { name string composeFileContent string expectFileContent string envFileContent string env []string }{ { name: "compose file with relative path", composeFileContent: `services: app: image: 'nginx:latest' ports: - '80:80' volumes: - ./nginx-data:/data`, expectFileContent: `name: configtest services: app: image: nginx:latest networks: default: null ports: - mode: ingress target: 80 published: "80" protocol: tcp volumes: - type: bind source: ./nginx-data target: /data bind: create_host_path: true networks: default: name: configtest_default `, }, { name: "compose file with absolute path", composeFileContent: `services: app: image: 'nginx:latest' ports: - '80:80' volumes: - /nginx-data:/data`, expectFileContent: `name: configtest services: app: image: nginx:latest networks: default: null ports: - mode: ingress target: 80 published: "80" protocol: tcp volumes: - type: bind source: /nginx-data target: /data bind: create_host_path: true networks: default: name: configtest_default `, }, { name: "compose file with declared volume", composeFileContent: `services: app: image: 'nginx:latest' ports: - '80:80' volumes: - nginx-data:/data volumes: nginx-data: driver: local`, expectFileContent: `name: configtest services: app: image: nginx:latest networks: default: null ports: - mode: ingress target: 80 published: "80" protocol: tcp volumes: - type: volume source: nginx-data target: /data volume: {} networks: default: name: configtest_default volumes: nginx-data: name: configtest_nginx-data driver: local `, }, { name: "compose file with relative path environment variable placeholder", composeFileContent: `services: nginx: image: nginx:latest ports: - 8019:80 volumes: - ${WEB_HOME}:/usr/share/nginx/html/ - ./config/${CONFIG_DIR}:/tmp/config env_file: - stack.env `, expectFileContent: `name: configtest services: nginx: environment: WEB_HOME: ./html image: nginx:latest networks: default: null ports: - mode: ingress target: 80 published: "8019" protocol: tcp volumes: - type: bind source: ./html target: /usr/share/nginx/html bind: create_host_path: true - type: bind source: ./config/something target: /tmp/config bind: create_host_path: true networks: default: name: configtest_default `, envFileContent: `WEB_HOME=./html`, env: []string{"CONFIG_DIR=something"}, }, { name: "compose file with absolute path environment variable placeholder", composeFileContent: `services: nginx: image: nginx:latest ports: - 8019:80 volumes: - ${WEB_HOME}:/usr/share/nginx/html/ env_file: - stack.env `, expectFileContent: `name: configtest services: nginx: environment: WEB_HOME: /usr/share/nginx/html image: nginx:latest networks: default: null ports: - mode: ingress target: 80 published: "8019" protocol: tcp volumes: - type: bind source: /usr/share/nginx/html target: /usr/share/nginx/html bind: create_host_path: true networks: default: name: configtest_default `, envFileContent: `WEB_HOME=/usr/share/nginx/html`, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { composeFilePath := createFile(t, dir, "docker-compose.yml", tc.composeFileContent) envFilePath := "" if tc.envFileContent != "" { envFilePath = createFile(t, dir, "stack.env", tc.envFileContent) } w := NewComposeDeployer() actual, err := w.Config(ctx, []string{composeFilePath}, libstack.Options{ WorkingDir: dir, ProjectName: projectName, EnvFilePath: envFilePath, Env: tc.env, ConfigOptions: []string{"--no-path-resolution"}, }) require.NoError(t, err) require.Equal(t, tc.expectFileContent, string(actual)) }) } } 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 osEnv map[string]string 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{ // Note that this is the execution working directory not the compose project working directory // and so it has no affect on the created projects working directory WorkingDir: "/something-totally-different", ProjectName: projectName, }, expectedProject: expectedSimpleComposeProject("", nil), }, { name: "Relative Working Directory", filesToCreate: map[string]string{ "docker-compose.yml": testSimpleComposeConfig, }, configFilepaths: []string{dir + "/docker-compose.yml"}, options: libstack.Options{ // Note that this is the execution working directory not the compose project working directory // and so it has no affect on the created projects working directory WorkingDir: "something-totally-different", ProjectName: projectName, }, expectedProject: expectedSimpleComposeProject("", 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), }, { name: "OS Env Vars", filesToCreate: map[string]string{ "docker-compose.yml": testSimpleComposeConfig, }, configFilepaths: []string{dir + "/docker-compose.yml"}, options: libstack.Options{ ProjectName: projectName, }, osEnv: map[string]string{ "PORTAINER_WEB_FOLDER": "html-1", "other_var": "something", }, expectedProject: expectedSimpleComposeProject("", map[string]string{"PORTAINER_WEB_FOLDER": "html-1"}), }, { name: "Env Vars in compose file, compose env file, env, os, and env_file", filesToCreate: map[string]string{ "docker-compose.yml": `services: nginx: container_name: nginx image: nginx:latest env_file: ` + dir + `/compose-stack.env environment: PORTAINER_VAR: compose_file_environment`, "stack.env": "PORTAINER_VAR=env_file", "compose-stack.env": "PORTAINER_VAR=compose_env_file", }, configFilepaths: []string{dir + "/docker-compose.yml"}, options: libstack.Options{ ProjectName: projectName, Env: []string{"PORTAINER_VAR=env"}, EnvFilePath: dir + "/stack.env", }, osEnv: map[string]string{ "PORTAINER_VAR": "os", }, expectedProject: &types.Project{ Name: projectName, WorkingDir: dir, Services: types.Services{ "nginx": { Name: "nginx", ContainerName: "nginx", Environment: types.NewMappingWithEquals([]string{"PORTAINER_VAR=compose_file_environment"}), 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: map[string]string{"COMPOSE_PROJECT_NAME": "create-project-test", "PORTAINER_VAR": "env"}, DisabledServices: types.Services{}, Profiles: []string{""}, }, }, { name: "Env Vars in compose env file, env, os, and env_file", filesToCreate: map[string]string{ "docker-compose.yml": `services: nginx: container_name: nginx image: nginx:latest env_file: ` + dir + `/compose-stack.env`, "stack.env": "PORTAINER_VAR=env_file", "compose-stack.env": "PORTAINER_VAR=compose_env_file", }, configFilepaths: []string{dir + "/docker-compose.yml"}, options: libstack.Options{ ProjectName: projectName, Env: []string{"PORTAINER_VAR=env"}, EnvFilePath: dir + "/stack.env", }, osEnv: map[string]string{ "PORTAINER_VAR": "os", }, expectedProject: &types.Project{ Name: projectName, WorkingDir: dir, Services: types.Services{ "nginx": { Name: "nginx", ContainerName: "nginx", Environment: types.NewMappingWithEquals([]string{"PORTAINER_VAR=compose_env_file"}), 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: map[string]string{"COMPOSE_PROJECT_NAME": "create-project-test", "PORTAINER_VAR": "env"}, DisabledServices: types.Services{}, Profiles: []string{""}, }, }, { name: "Env Vars in env, os, and env_file", filesToCreate: map[string]string{ "docker-compose.yml": `services: nginx: container_name: nginx image: nginx:latest`, "stack.env": "PORTAINER_VAR=env_file", }, configFilepaths: []string{dir + "/docker-compose.yml"}, options: libstack.Options{ ProjectName: projectName, Env: []string{"PORTAINER_VAR=env"}, EnvFilePath: dir + "/stack.env", }, osEnv: map[string]string{ "PORTAINER_VAR": "os", }, 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}, }, }, Networks: types.Networks{"default": {Name: "create-project-test_default"}}, ComposeFiles: []string{ dir + "/docker-compose.yml", }, Environment: map[string]string{"COMPOSE_PROJECT_NAME": "create-project-test", "PORTAINER_VAR": "env"}, DisabledServices: types.Services{}, Profiles: []string{""}, }, }, { name: "Env Vars in os and env_file", filesToCreate: map[string]string{ "docker-compose.yml": `services: nginx: container_name: nginx image: nginx:latest`, "stack.env": "PORTAINER_VAR=env_file", }, configFilepaths: []string{dir + "/docker-compose.yml"}, options: libstack.Options{ ProjectName: projectName, EnvFilePath: dir + "/stack.env", }, osEnv: map[string]string{ "PORTAINER_VAR": "os", }, 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}, }, }, Networks: types.Networks{"default": {Name: "create-project-test_default"}}, ComposeFiles: []string{ dir + "/docker-compose.yml", }, Environment: map[string]string{"COMPOSE_PROJECT_NAME": "create-project-test", "PORTAINER_VAR": "os"}, DisabledServices: types.Services{}, Profiles: []string{""}, }, }, { name: "Env Vars in env_file", filesToCreate: map[string]string{ "docker-compose.yml": `services: nginx: container_name: nginx image: nginx:latest`, "stack.env": "PORTAINER_VAR=env_file", }, configFilepaths: []string{dir + "/docker-compose.yml"}, options: libstack.Options{ ProjectName: projectName, EnvFilePath: dir + "/stack.env", }, 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}, }, }, Networks: types.Networks{"default": {Name: "create-project-test_default"}}, ComposeFiles: []string{ dir + "/docker-compose.yml", }, Environment: map[string]string{"COMPOSE_PROJECT_NAME": "create-project-test", "PORTAINER_VAR": "env_file"}, DisabledServices: types.Services{}, Profiles: []string{""}, }, }, } 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) } }() for k, v := range tc.osEnv { t.Setenv(k, v) } 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 }