mirror of
https://github.com/portainer/portainer.git
synced 2025-07-19 05:19:39 +02:00
fix(libstack): correctly load COMPOSE_* env vars [BE-11474] (#536)
This commit is contained in:
parent
5d1cd670e9
commit
34235199dd
7 changed files with 952 additions and 87 deletions
1
go.mod
1
go.mod
|
@ -192,6 +192,7 @@ require (
|
||||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
|
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
|
||||||
github.com/miekg/pkcs11 v1.1.1 // indirect
|
github.com/miekg/pkcs11 v1.1.1 // indirect
|
||||||
github.com/mitchellh/copystructure v1.2.0 // 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/go-wordwrap v1.0.1 // indirect
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
|
2
go.sum
2
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/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 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
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 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
||||||
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||||
|
|
|
@ -1,8 +1,18 @@
|
||||||
package compose
|
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
|
// NewComposeDeployer creates a new compose deployer
|
||||||
func NewComposeDeployer() *ComposeDeployer {
|
func NewComposeDeployer() *ComposeDeployer {
|
||||||
return &ComposeDeployer{}
|
return &ComposeDeployer{
|
||||||
|
createComposeServiceFn: compose.NewComposeService,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
@ -15,13 +14,14 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/pkg/libstack"
|
"github.com/portainer/portainer/pkg/libstack"
|
||||||
|
|
||||||
"github.com/compose-spec/compose-go/v2/dotenv"
|
"github.com/compose-spec/compose-go/v2/cli"
|
||||||
"github.com/compose-spec/compose-go/v2/loader"
|
|
||||||
"github.com/compose-spec/compose-go/v2/types"
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
"github.com/docker/cli/cli/command"
|
"github.com/docker/cli/cli/command"
|
||||||
"github.com/docker/cli/cli/flags"
|
"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/api"
|
||||||
"github.com/docker/compose/v2/pkg/compose"
|
"github.com/docker/compose/v2/pkg/compose"
|
||||||
|
"github.com/docker/compose/v2/pkg/utils"
|
||||||
"github.com/docker/docker/registry"
|
"github.com/docker/docker/registry"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
@ -75,66 +75,34 @@ func withCli(
|
||||||
return cliFn(ctx, cli)
|
return cliFn(ctx, cli)
|
||||||
}
|
}
|
||||||
|
|
||||||
func withComposeService(
|
func (c *ComposeDeployer) withComposeService(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
filePaths []string,
|
filePaths []string,
|
||||||
options libstack.Options,
|
options libstack.Options,
|
||||||
composeFn func(api.Service, *types.Project) error,
|
composeFn func(api.Service, *types.Project) error,
|
||||||
) error {
|
) error {
|
||||||
return withCli(ctx, options, func(ctx context.Context, cli *command.DockerCli) 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 {
|
if len(filePaths) == 0 {
|
||||||
return composeFn(composeService, nil)
|
return composeFn(composeService, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
env, err := parseEnvironment(options, filePaths)
|
project, err := createProject(ctx, filePaths, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to create compose project: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
configDetails := types.ConfigDetails{
|
parallel := 0
|
||||||
Environment: env,
|
if v, ok := project.Environment[cmdcompose.ComposeParallelLimit]; ok {
|
||||||
WorkingDir: filepath.Dir(filePaths[0]),
|
i, err := strconv.Atoi(v)
|
||||||
}
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s must be an integer (found: %q)", cmdcompose.ComposeParallelLimit, v)
|
||||||
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 = i
|
||||||
}
|
}
|
||||||
|
if parallel > 0 {
|
||||||
// Set the services environment variables
|
composeService.MaxConcurrency(parallel)
|
||||||
if p, err := project.WithServicesEnvironmentResolved(true); err == nil {
|
|
||||||
project = p
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("failed to resolve services environment: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return composeFn(composeService, project)
|
return composeFn(composeService, project)
|
||||||
|
@ -143,7 +111,7 @@ func withComposeService(
|
||||||
|
|
||||||
// Deploy creates and starts containers
|
// Deploy creates and starts containers
|
||||||
func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, options libstack.DeployOptions) error {
|
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)
|
addServiceLabels(project, false, options.EdgeStackID)
|
||||||
|
|
||||||
project = project.WithoutUnnecessaryResources()
|
project = project.WithoutUnnecessaryResources()
|
||||||
|
@ -154,6 +122,12 @@ func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, option
|
||||||
}
|
}
|
||||||
|
|
||||||
opts.Create.RemoveOrphans = options.RemoveOrphans
|
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 {
|
if options.AbortOnContainerExit {
|
||||||
opts.Start.OnExit = api.CascadeStop
|
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
|
// 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 {
|
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)
|
addServiceLabels(project, true, 0)
|
||||||
|
|
||||||
for name, service := range project.Services {
|
for name, service := range project.Services {
|
||||||
|
@ -227,7 +201,7 @@ func (c *ComposeDeployer) Remove(ctx context.Context, projectName string, filePa
|
||||||
|
|
||||||
// Pull pulls images
|
// Pull pulls images
|
||||||
func (c *ComposeDeployer) Pull(ctx context.Context, filePaths []string, options libstack.Options) error {
|
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{})
|
return composeService.Pull(ctx, project, api.PullOptions{})
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("compose pull operation failed: %w", err)
|
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
|
// Validate validates stack file
|
||||||
func (c *ComposeDeployer) Validate(ctx context.Context, filePaths []string, options libstack.Options) error {
|
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
|
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) {
|
func (c *ComposeDeployer) Config(ctx context.Context, filePaths []string, options libstack.Options) ([]byte, error) {
|
||||||
var payload []byte
|
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
|
var err error
|
||||||
payload, err = project.MarshalYAML()
|
payload, err = project.MarshalYAML()
|
||||||
if err != nil {
|
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) {
|
func (c *ComposeDeployer) GetExistingEdgeStacks(ctx context.Context) ([]libstack.EdgeStack, error) {
|
||||||
m := make(map[int]libstack.EdgeStack)
|
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{
|
stacks, err := composeService.List(ctx, api.ListOptions{
|
||||||
All: true,
|
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) {
|
func createProject(ctx context.Context, configFilepaths []string, options libstack.Options) (*types.Project, error) {
|
||||||
env := make(map[string]string)
|
var workingDir string
|
||||||
|
if len(configFilepaths) > 0 {
|
||||||
for _, envLine := range options.Env {
|
workingDir = filepath.Dir(configFilepaths[0])
|
||||||
e, err := dotenv.UnmarshalWithLookup(envLine, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("unable to parse environment variables: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
maps.Copy(env, e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.EnvFilePath == "" {
|
if options.WorkingDir != "" {
|
||||||
if len(filePaths) == 0 {
|
workingDir = options.WorkingDir
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,14 +2,23 @@ package compose
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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"
|
"github.com/portainer/portainer/pkg/libstack"
|
||||||
|
zerolog "github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"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
|
||||||
|
}
|
||||||
|
|
109
pkg/libstack/compose/composeplugin_windows_test.go
Normal file
109
pkg/libstack/compose/composeplugin_windows_test.go
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/portainer/portainer/pkg/libstack"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_createProject_win(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
projectName := "create-project-test"
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err := os.RemoveAll(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to remove temp dir: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
testcases := []struct {
|
||||||
|
name string
|
||||||
|
createFilesFn func() []string
|
||||||
|
configFilepaths []string
|
||||||
|
options libstack.Options
|
||||||
|
expectedProject *types.Project
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Convert windows paths",
|
||||||
|
createFilesFn: func() []string {
|
||||||
|
var filepaths []string
|
||||||
|
filepaths = append(filepaths, createFile(t, dir, "docker-compose.yml", `services:
|
||||||
|
nginx:
|
||||||
|
container_name: nginx
|
||||||
|
image: nginx:latest
|
||||||
|
volumes:
|
||||||
|
- "C:\\Users\\Joey\\Desktop\\backend:/var/www/html"`))
|
||||||
|
return filepaths
|
||||||
|
},
|
||||||
|
configFilepaths: []string{dir + "/docker-compose.yml"},
|
||||||
|
options: libstack.Options{
|
||||||
|
WorkingDir: dir,
|
||||||
|
ProjectName: projectName,
|
||||||
|
Env: []string{"COMPOSE_CONVERT_WINDOWS_PATHS=true"},
|
||||||
|
},
|
||||||
|
expectedProject: &types.Project{
|
||||||
|
Name: projectName,
|
||||||
|
WorkingDir: dir,
|
||||||
|
Services: types.Services{
|
||||||
|
"nginx": {
|
||||||
|
Name: "nginx",
|
||||||
|
ContainerName: "nginx",
|
||||||
|
Environment: types.MappingWithEquals{},
|
||||||
|
Image: "nginx:latest",
|
||||||
|
Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
|
||||||
|
Volumes: []types.ServiceVolumeConfig{
|
||||||
|
{
|
||||||
|
Type: "bind",
|
||||||
|
Source: "/c/Users/Joey/Desktop/backend",
|
||||||
|
Target: "/var/www/html",
|
||||||
|
ReadOnly: false,
|
||||||
|
Bind: &types.ServiceVolumeBind{CreateHostPath: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Networks: types.Networks{"default": {Name: "create-project-test_default"}},
|
||||||
|
ComposeFiles: []string{
|
||||||
|
dir + "/docker-compose.yml",
|
||||||
|
},
|
||||||
|
Environment: types.Mapping{"COMPOSE_PROJECT_NAME": "create-project-test", "COMPOSE_CONVERT_WINDOWS_PATHS": "true"},
|
||||||
|
DisabledServices: types.Services{},
|
||||||
|
Profiles: []string{""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testcases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
createdFiles := tc.createFilesFn()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
var errs []error
|
||||||
|
for _, f := range createdFiles {
|
||||||
|
errs = append(errs, os.Remove(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
err := errors.Join(errs...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to remove config files: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
gotProject, err := createProject(ctx, tc.configFilepaths, tc.options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create new project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if diff := cmp.Diff(gotProject, tc.expectedProject); diff != "" {
|
||||||
|
t.Fatalf("Projects are different:\n%s", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -125,7 +125,7 @@ func (c *ComposeDeployer) WaitForStatus(ctx context.Context, name string, status
|
||||||
|
|
||||||
var containerSummaries []api.ContainerSummary
|
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
|
var err error
|
||||||
|
|
||||||
psCtx, cancelFunc := context.WithTimeout(context.Background(), time.Minute)
|
psCtx, cancelFunc := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue