diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index f10100c25..f615f1236 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -75,12 +75,12 @@ func initDataStore(dataStorePath string, fileService portainer.FileService) port } func initComposeStackManager(assetsPath string, dataStorePath string, reverseTunnelService portainer.ReverseTunnelService, proxyManager *proxy.Manager) portainer.ComposeStackManager { - composeWrapper := exec.NewComposeWrapper(assetsPath, proxyManager) - if composeWrapper != nil { - return composeWrapper + composeWrapper, err := exec.NewComposeStackManager(assetsPath, proxyManager) + if err != nil { + return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService) } - return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService) + return composeWrapper } func initSwarmStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) (portainer.SwarmStackManager, error) { diff --git a/api/exec/compose_stack.go b/api/exec/compose_stack.go new file mode 100644 index 000000000..954a7aa43 --- /dev/null +++ b/api/exec/compose_stack.go @@ -0,0 +1,112 @@ +package exec + +import ( + "fmt" + "os" + "path" + "strings" + + wrapper "github.com/portainer/docker-compose-wrapper" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy" +) + +// ComposeStackManager is a wrapper for docker-compose binary +type ComposeStackManager struct { + wrapper *wrapper.ComposeWrapper + proxyManager *proxy.Manager +} + +// NewComposeStackManager returns a docker-compose wrapper if corresponding binary present, otherwise nil +func NewComposeStackManager(binaryPath string, proxyManager *proxy.Manager) (*ComposeStackManager, error) { + wrap, err := wrapper.NewComposeWrapper(binaryPath) + if err != nil { + return nil, err + } + + return &ComposeStackManager{ + wrapper: wrap, + proxyManager: proxyManager, + }, nil +} + +// NormalizeStackName returns a new stack name with unsupported characters replaced +func (w *ComposeStackManager) NormalizeStackName(name string) string { + return name +} + +// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax +func (w *ComposeStackManager) ComposeSyntaxMaxVersion() string { + return portainer.ComposeSyntaxMaxVersion +} + +// Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command +func (w *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + url, err := w.fetchEndpointURL(endpoint) + if err != nil { + return err + } + + envFilePath, err := createEnvFile(stack) + if err != nil { + return err + } + + filePath := stackFilePath(stack) + + _, err = w.wrapper.Up(filePath, url, stack.Name, envFilePath) + return err +} + +// Down stops and removes containers, networks, images, and volumes. Wraps `docker-compose down --remove-orphans` command +func (w *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + url, err := w.fetchEndpointURL(endpoint) + if err != nil { + return err + } + + filePath := stackFilePath(stack) + + _, err = w.wrapper.Down(filePath, url, stack.Name) + return err +} + +func stackFilePath(stack *portainer.Stack) string { + return path.Join(stack.ProjectPath, stack.EntryPoint) +} + +func (w *ComposeStackManager) fetchEndpointURL(endpoint *portainer.Endpoint) (string, error) { + if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") { + return "", nil + } + + proxy, err := w.proxyManager.CreateComposeProxyServer(endpoint) + if err != nil { + return "", err + } + + defer proxy.Close() + + return fmt.Sprintf("http://127.0.0.1:%d", proxy.Port), nil +} + +func createEnvFile(stack *portainer.Stack) (string, error) { + if stack.Env == nil || len(stack.Env) == 0 { + return "", nil + } + + envFilePath := path.Join(stack.ProjectPath, "stack.env") + + envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return "", err + } + + for _, v := range stack.Env { + envfile.WriteString(fmt.Sprintf("%s=%s\n", v.Name, v.Value)) + } + envfile.Close() + + return envFilePath, nil +} diff --git a/api/exec/compose_wrapper_integration_test.go b/api/exec/compose_stack_integration_test.go similarity index 76% rename from api/exec/compose_wrapper_integration_test.go rename to api/exec/compose_stack_integration_test.go index 766622614..dcf9d9c24 100644 --- a/api/exec/compose_wrapper_integration_test.go +++ b/api/exec/compose_stack_integration_test.go @@ -1,5 +1,4 @@ // +build integration - package exec import ( @@ -33,7 +32,9 @@ func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) { Name: "project-name", } - endpoint := &portainer.Endpoint{} + endpoint := &portainer.Endpoint{ + URL: "unix://", + } return stack, endpoint } @@ -42,14 +43,17 @@ func Test_UpAndDown(t *testing.T) { stack, endpoint := setup(t) - w := NewComposeWrapper("", nil) + w, err := NewComposeStackManager("", nil) + if err != nil { + t.Fatalf("Failed creating manager: %s", err) + } - err := w.Up(stack, endpoint) + err = w.Up(stack, endpoint) if err != nil { t.Fatalf("Error calling docker-compose up: %s", err) } - if containerExists(composedContainerName) == false { + if !containerExists(composedContainerName) { t.Fatal("container should exist") } @@ -63,13 +67,13 @@ func Test_UpAndDown(t *testing.T) { } } -func containerExists(contaierName string) bool { - cmd := exec.Command(osProgram("docker"), "ps", "-a", "-f", fmt.Sprintf("name=%s", contaierName)) +func containerExists(containerName string) bool { + cmd := exec.Command(osProgram("docker"), "ps", "-a", "-f", fmt.Sprintf("name=%s", containerName)) out, err := cmd.Output() if err != nil { log.Fatalf("failed to list containers: %s", err) } - return strings.Contains(string(out), contaierName) + return strings.Contains(string(out), containerName) } diff --git a/api/exec/compose_stack_test.go b/api/exec/compose_stack_test.go new file mode 100644 index 000000000..80e2818df --- /dev/null +++ b/api/exec/compose_stack_test.go @@ -0,0 +1,112 @@ +package exec + +import ( + "io/ioutil" + "os" + "path" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +func Test_stackFilePath(t *testing.T) { + tests := []struct { + name string + stack *portainer.Stack + expected string + }{ + // { + // name: "should return empty result if stack is missing", + // stack: nil, + // expected: "", + // }, + // { + // name: "should return empty result if stack don't have entrypoint", + // stack: &portainer.Stack{}, + // expected: "", + // }, + { + name: "should allow file name and dir", + stack: &portainer.Stack{ + ProjectPath: "dir", + EntryPoint: "file", + }, + expected: path.Join("dir", "file"), + }, + { + name: "should allow file name only", + stack: &portainer.Stack{ + EntryPoint: "file", + }, + expected: "file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := stackFilePath(tt.stack) + assert.Equal(t, tt.expected, result) + }) + } +} + +func Test_createEnvFile(t *testing.T) { + dir := t.TempDir() + + tests := []struct { + name string + stack *portainer.Stack + expected string + expectedFile bool + }{ + // { + // name: "should not add env file option if stack is missing", + // stack: nil, + // expected: "", + // }, + { + name: "should not add env file option if stack doesn't have env variables", + stack: &portainer.Stack{ + ProjectPath: dir, + }, + expected: "", + }, + { + name: "should not add env file option if stack's env variables are empty", + stack: &portainer.Stack{ + ProjectPath: dir, + Env: []portainer.Pair{}, + }, + expected: "", + }, + { + name: "should add env file option if stack has env variables", + stack: &portainer.Stack{ + ProjectPath: dir, + Env: []portainer.Pair{ + {Name: "var1", Value: "value1"}, + {Name: "var2", Value: "value2"}, + }, + }, + expected: "var1=value1\nvar2=value2\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _ := createEnvFile(tt.stack) + + if tt.expected != "" { + assert.Equal(t, path.Join(tt.stack.ProjectPath, "stack.env"), result) + + f, _ := os.Open(path.Join(dir, "stack.env")) + content, _ := ioutil.ReadAll(f) + + assert.Equal(t, tt.expected, string(content)) + } else { + assert.Equal(t, "", result) + } + }) + } +} diff --git a/api/exec/compose_wrapper.go b/api/exec/compose_wrapper.go deleted file mode 100644 index 918411685..000000000 --- a/api/exec/compose_wrapper.go +++ /dev/null @@ -1,137 +0,0 @@ -package exec - -import ( - "bytes" - "errors" - "fmt" - "os" - "os/exec" - "path" - "strings" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy" -) - -// ComposeWrapper is a wrapper for docker-compose binary -type ComposeWrapper struct { - binaryPath string - proxyManager *proxy.Manager -} - -// NewComposeWrapper returns a docker-compose wrapper if corresponding binary present, otherwise nil -func NewComposeWrapper(binaryPath string, proxyManager *proxy.Manager) *ComposeWrapper { - if !IsBinaryPresent(programPath(binaryPath, "docker-compose")) { - return nil - } - - return &ComposeWrapper{ - binaryPath: binaryPath, - proxyManager: proxyManager, - } -} - -// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax -func (w *ComposeWrapper) ComposeSyntaxMaxVersion() string { - return portainer.ComposeSyntaxMaxVersion -} - -// NormalizeStackName returns a new stack name with unsupported characters replaced -func (w *ComposeWrapper) NormalizeStackName(name string) string { - return name -} - -// Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command -func (w *ComposeWrapper) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error { - _, err := w.command([]string{"up", "-d"}, stack, endpoint) - return err -} - -// Down stops and removes containers, networks, images, and volumes. Wraps `docker-compose down --remove-orphans` command -func (w *ComposeWrapper) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error { - _, err := w.command([]string{"down", "--remove-orphans"}, stack, endpoint) - return err -} - -func (w *ComposeWrapper) command(command []string, stack *portainer.Stack, endpoint *portainer.Endpoint) ([]byte, error) { - if endpoint == nil { - return nil, errors.New("cannot call a compose command on an empty endpoint") - } - - program := programPath(w.binaryPath, "docker-compose") - - options := setComposeFile(stack) - - options = addProjectNameOption(options, stack) - options, err := addEnvFileOption(options, stack) - if err != nil { - return nil, err - } - - if !(endpoint.URL == "" || strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://")) { - - proxy, err := w.proxyManager.CreateComposeProxyServer(endpoint) - if err != nil { - return nil, err - } - - defer proxy.Close() - - options = append(options, "-H", fmt.Sprintf("http://127.0.0.1:%d", proxy.Port)) - } - - args := append(options, command...) - - var stderr bytes.Buffer - cmd := exec.Command(program, args...) - cmd.Stderr = &stderr - - out, err := cmd.Output() - if err != nil { - return out, errors.New(stderr.String()) - } - - return out, nil -} - -func setComposeFile(stack *portainer.Stack) []string { - options := make([]string, 0) - - if stack == nil || stack.EntryPoint == "" { - return options - } - - composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) - options = append(options, "-f", composeFilePath) - return options -} - -func addProjectNameOption(options []string, stack *portainer.Stack) []string { - if stack == nil || stack.Name == "" { - return options - } - - options = append(options, "-p", stack.Name) - return options -} - -func addEnvFileOption(options []string, stack *portainer.Stack) ([]string, error) { - if stack == nil || stack.Env == nil || len(stack.Env) == 0 { - return options, nil - } - - envFilePath := path.Join(stack.ProjectPath, "stack.env") - - envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return options, err - } - - for _, v := range stack.Env { - envfile.WriteString(fmt.Sprintf("%s=%s\n", v.Name, v.Value)) - } - envfile.Close() - - options = append(options, "--env-file", envFilePath) - return options, nil -} diff --git a/api/exec/compose_wrapper_test.go b/api/exec/compose_wrapper_test.go deleted file mode 100644 index caee859ef..000000000 --- a/api/exec/compose_wrapper_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package exec - -import ( - "io/ioutil" - "os" - "path" - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/stretchr/testify/assert" -) - -func Test_setComposeFile(t *testing.T) { - tests := []struct { - name string - stack *portainer.Stack - expected []string - }{ - { - name: "should return empty result if stack is missing", - stack: nil, - expected: []string{}, - }, - { - name: "should return empty result if stack don't have entrypoint", - stack: &portainer.Stack{}, - expected: []string{}, - }, - { - name: "should allow file name and dir", - stack: &portainer.Stack{ - ProjectPath: "dir", - EntryPoint: "file", - }, - expected: []string{"-f", path.Join("dir", "file")}, - }, - { - name: "should allow file name only", - stack: &portainer.Stack{ - EntryPoint: "file", - }, - expected: []string{"-f", "file"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := setComposeFile(tt.stack) - assert.ElementsMatch(t, tt.expected, result) - }) - } -} - -func Test_addProjectNameOption(t *testing.T) { - tests := []struct { - name string - stack *portainer.Stack - expected []string - }{ - { - name: "should not add project option if stack is missing", - stack: nil, - expected: []string{}, - }, - { - name: "should not add project option if stack doesn't have name", - stack: &portainer.Stack{}, - expected: []string{}, - }, - { - name: "should add project name option if stack has a name", - stack: &portainer.Stack{ - Name: "project-name", - }, - expected: []string{"-p", "project-name"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - options := []string{"-a", "b"} - result := addProjectNameOption(options, tt.stack) - assert.ElementsMatch(t, append(options, tt.expected...), result) - }) - } -} - -func Test_addEnvFileOption(t *testing.T) { - dir := t.TempDir() - - tests := []struct { - name string - stack *portainer.Stack - expected []string - expectedContent string - }{ - { - name: "should not add env file option if stack is missing", - stack: nil, - expected: []string{}, - }, - { - name: "should not add env file option if stack doesn't have env variables", - stack: &portainer.Stack{}, - expected: []string{}, - }, - { - name: "should not add env file option if stack's env variables are empty", - stack: &portainer.Stack{ - ProjectPath: dir, - Env: []portainer.Pair{}, - }, - expected: []string{}, - }, - { - name: "should add env file option if stack has env variables", - stack: &portainer.Stack{ - ProjectPath: dir, - Env: []portainer.Pair{ - {Name: "var1", Value: "value1"}, - {Name: "var2", Value: "value2"}, - }, - }, - expected: []string{"--env-file", path.Join(dir, "stack.env")}, - expectedContent: "var1=value1\nvar2=value2\n", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - options := []string{"-a", "b"} - result, _ := addEnvFileOption(options, tt.stack) - assert.ElementsMatch(t, append(options, tt.expected...), result) - - if tt.expectedContent != "" { - f, _ := os.Open(path.Join(dir, "stack.env")) - content, _ := ioutil.ReadAll(f) - - assert.Equal(t, tt.expectedContent, string(content)) - } - }) - } -} diff --git a/api/go.mod b/api/go.mod index f8cca5616..dd26045eb 100644 --- a/api/go.mod +++ b/api/go.mod @@ -25,6 +25,7 @@ require ( github.com/mattn/go-shellwords v1.0.6 // indirect github.com/mitchellh/mapstructure v1.1.2 // indirect github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6 + github.com/portainer/docker-compose-wrapper v0.0.0-20210107071118-d05a74a4b3f2 github.com/portainer/libcompose v0.5.3 github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2 github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33 diff --git a/api/go.sum b/api/go.sum index 237ef4cd2..825347bf1 100644 --- a/api/go.sum +++ b/api/go.sum @@ -226,6 +226,8 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/portainer/docker-compose-wrapper v0.0.0-20210107071118-d05a74a4b3f2 h1:53PCXhNlb+HXrPa4GCaoWeVEY7mElYjpFJwB6kQIu7w= +github.com/portainer/docker-compose-wrapper v0.0.0-20210107071118-d05a74a4b3f2/go.mod h1:PF2O2O4UNYWdtPcp6n/mIKpKk+f1jhFTezS8txbf+XM= github.com/portainer/libcompose v0.5.3 h1:tE4WcPuGvo+NKeDkDWpwNavNLZ5GHIJ4RvuZXsI9uI8= github.com/portainer/libcompose v0.5.3/go.mod h1:7SKd/ho69rRKHDFSDUwkbMcol2TMKU5OslDsajr8Ro8= github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2 h1:0PfgGLys9yHr4rtnirg0W0Cjvv6/DzxBIZk5sV59208=