1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-19 21:39:40 +02:00
portainer/pkg/libstack/compose/composeplugin_test.go

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1313 lines
38 KiB
Go
Raw Normal View History

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
}