diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md index 3e9b69019..352cfec59 100644 --- a/.github/ISSUE_TEMPLATE/Bug_report.md +++ b/.github/ISSUE_TEMPLATE/Bug_report.md @@ -1,6 +1,10 @@ --- name: Bug report about: Create a bug report +title: '' +labels: bug/need-confirmation, kind/bug +assignees: '' + --- - -**Question**: -How can I deploy Portainer on... ? +--- +name: Question +about: Ask us a question about Portainer usage or deployment +title: '' +labels: '' +assignees: '' + +--- + + + +**Question**: +How can I deploy Portainer on... ? diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md b/.github/ISSUE_TEMPLATE/Feature_request.md index 6da5f4265..8c2bee587 100644 --- a/.github/ISSUE_TEMPLATE/Feature_request.md +++ b/.github/ISSUE_TEMPLATE/Feature_request.md @@ -1,31 +1,34 @@ ---- -name: Feature request -about: Suggest a feature/enhancement that should be added in Portainer - ---- - - - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. +--- +name: Feature request +about: Suggest a feature/enhancement that should be added in Portainer +title: '' +labels: '' +assignees: '' + +--- + + + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/stale.yml b/.github/stale.yml index a5d534d01..fce5d3700 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -15,6 +15,7 @@ issues: - kind/question - kind/style - kind/workaround + - kind/refactor - bug/need-confirmation - bug/confirmed - status/discuss diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 537ae511f..622876590 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,3 +74,23 @@ Our contribution process is described below. Some of the steps can be visualized The feature request process is similar to the bug report process but has an extra functional validation before the technical validation as well as a documentation validation before the testing phase. ![portainer_featurerequest_workflow](https://user-images.githubusercontent.com/5485061/45727229-5ad39f00-bbf5-11e8-9550-16ba66c50615.png) + +## Build Portainer locally + +Ensure you have Docker, Node.js, yarn, and Golang installed in the correct versions. + +Install dependencies with yarn: + +```sh +$ yarn +``` + +Then build and run the project: + +```sh +$ yarn start +``` + +Portainer can now be accessed at . + +Find more detailed steps at . diff --git a/README.md b/README.md index 9da782e5a..18fefca5f 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,13 @@ Unlike the public demo, the playground sessions are deleted after 4 hours. Apart - [Deploy Portainer](https://www.portainer.io/installation/) - [Documentation](https://documentation.portainer.io) +- [Building Portainer](https://documentation.portainer.io/contributing/instructions/) ## Getting help -For FORMAL Support, please purchase a support subscription from here: https://www.portainer.io/products-services/portainer-business-support/ +For FORMAL Support, please purchase a support subscription from here: https://www.portainer.io/products/portainer-business -For community support: You can find more information about Portainer's community support framework policy here: https://www.portainer.io/2019/04/portainer-support-policy/ +For community support: You can find more information about Portainer's community support framework policy here: https://www.portainer.io/products/community-edition/customer-success - Issues: https://github.com/portainer/portainer/issues - FAQ: https://documentation.portainer.io diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 5d83916be..229b0c0a5 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt" "github.com/portainer/portainer/api/chisel" "github.com/portainer/portainer/api/cli" @@ -17,6 +17,8 @@ import ( "github.com/portainer/portainer/api/git" "github.com/portainer/portainer/api/http" "github.com/portainer/portainer/api/http/client" + "github.com/portainer/portainer/api/http/proxy" + kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" "github.com/portainer/portainer/api/internal/snapshot" "github.com/portainer/portainer/api/jwt" "github.com/portainer/portainer/api/kubernetes" @@ -71,7 +73,12 @@ func initDataStore(dataStorePath string, fileService portainer.FileService) port return store } -func initComposeStackManager(dataStorePath string, reverseTunnelService portainer.ReverseTunnelService) portainer.ComposeStackManager { +func initComposeStackManager(assetsPath string, dataStorePath string, reverseTunnelService portainer.ReverseTunnelService, proxyManager *proxy.Manager) portainer.ComposeStackManager { + composeWrapper := exec.NewComposeWrapper(assetsPath, proxyManager) + if composeWrapper != nil { + return composeWrapper + } + return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService) } @@ -89,6 +96,10 @@ func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) return nil, err } + if settings.UserSessionTimeout == "" { + settings.UserSessionTimeout = portainer.DefaultUserSessionTimeout + dataStore.Settings().UpdateSettings(settings) + } jwtService, err := jwt.NewService(settings.UserSessionTimeout) if err != nil { return nil, err @@ -380,8 +391,10 @@ func main() { if err != nil { log.Fatal(err) } + kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager() + proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager) - composeStackManager := initComposeStackManager(*flags.Data, reverseTunnelService) + composeStackManager := initComposeStackManager(*flags.Assets, *flags.Data, reverseTunnelService, proxyManager) kubernetesDeployer := initKubernetesDeployer(*flags.Assets) @@ -448,27 +461,29 @@ func main() { } var server portainer.Server = &http.Server{ - ReverseTunnelService: reverseTunnelService, - Status: applicationStatus, - BindAddress: *flags.Addr, - AssetsPath: *flags.Assets, - DataStore: dataStore, - SwarmStackManager: swarmStackManager, - ComposeStackManager: composeStackManager, - KubernetesDeployer: kubernetesDeployer, - CryptoService: cryptoService, - JWTService: jwtService, - FileService: fileService, - LDAPService: ldapService, - OAuthService: oauthService, - GitService: gitService, - SignatureService: digitalSignatureService, - SnapshotService: snapshotService, - SSL: *flags.SSL, - SSLCert: *flags.SSLCert, - SSLKey: *flags.SSLKey, - DockerClientFactory: dockerClientFactory, - KubernetesClientFactory: kubernetesClientFactory, + ReverseTunnelService: reverseTunnelService, + Status: applicationStatus, + BindAddress: *flags.Addr, + AssetsPath: *flags.Assets, + DataStore: dataStore, + SwarmStackManager: swarmStackManager, + ComposeStackManager: composeStackManager, + KubernetesDeployer: kubernetesDeployer, + CryptoService: cryptoService, + JWTService: jwtService, + FileService: fileService, + LDAPService: ldapService, + OAuthService: oauthService, + GitService: gitService, + ProxyManager: proxyManager, + KubernetesTokenCacheManager: kubernetesTokenCacheManager, + SignatureService: digitalSignatureService, + SnapshotService: snapshotService, + SSL: *flags.SSL, + SSLCert: *flags.SSLCert, + SSLKey: *flags.SSLKey, + DockerClientFactory: dockerClientFactory, + KubernetesClientFactory: kubernetesClientFactory, } log.Printf("Starting Portainer %s on %s", portainer.APIVersion, *flags.Addr) diff --git a/api/exec/compose_wrapper.go b/api/exec/compose_wrapper.go new file mode 100644 index 000000000..5f91795fd --- /dev/null +++ b/api/exec/compose_wrapper.go @@ -0,0 +1,132 @@ +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 +} + +// 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_integration_test.go b/api/exec/compose_wrapper_integration_test.go new file mode 100644 index 000000000..766622614 --- /dev/null +++ b/api/exec/compose_wrapper_integration_test.go @@ -0,0 +1,75 @@ +// +build integration + +package exec + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + portainer "github.com/portainer/portainer/api" +) + +const composeFile = `version: "3.9" +services: + busybox: + image: "alpine:latest" + container_name: "compose_wrapper_test"` +const composedContainerName = "compose_wrapper_test" + +func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) { + dir := t.TempDir() + composeFileName := "compose_wrapper_test.yml" + f, _ := os.Create(filepath.Join(dir, composeFileName)) + f.WriteString(composeFile) + + stack := &portainer.Stack{ + ProjectPath: dir, + EntryPoint: composeFileName, + Name: "project-name", + } + + endpoint := &portainer.Endpoint{} + + return stack, endpoint +} + +func Test_UpAndDown(t *testing.T) { + + stack, endpoint := setup(t) + + w := NewComposeWrapper("", nil) + + err := w.Up(stack, endpoint) + if err != nil { + t.Fatalf("Error calling docker-compose up: %s", err) + } + + if containerExists(composedContainerName) == false { + t.Fatal("container should exist") + } + + err = w.Down(stack, endpoint) + if err != nil { + t.Fatalf("Error calling docker-compose down: %s", err) + } + + if containerExists(composedContainerName) { + t.Fatal("container should be removed") + } +} + +func containerExists(contaierName string) bool { + cmd := exec.Command(osProgram("docker"), "ps", "-a", "-f", fmt.Sprintf("name=%s", contaierName)) + + out, err := cmd.Output() + if err != nil { + log.Fatalf("failed to list containers: %s", err) + } + + return strings.Contains(string(out), contaierName) +} diff --git a/api/exec/compose_wrapper_test.go b/api/exec/compose_wrapper_test.go new file mode 100644 index 000000000..caee859ef --- /dev/null +++ b/api/exec/compose_wrapper_test.go @@ -0,0 +1,143 @@ +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/exec/swarm_stack.go b/api/exec/swarm_stack.go index 31fb48836..cf59f7607 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -134,6 +134,8 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, dataPa if !endpoint.TLSConfig.TLSSkipVerify { args = append(args, "--tlsverify", "--tlscacert", endpoint.TLSConfig.TLSCACertPath) + } else { + args = append(args, "--tlscacert", "''") } if endpoint.TLSConfig.TLSCertPath != "" && endpoint.TLSConfig.TLSKeyPath != "" { diff --git a/api/exec/utils.go b/api/exec/utils.go new file mode 100644 index 000000000..75a896f65 --- /dev/null +++ b/api/exec/utils.go @@ -0,0 +1,24 @@ +package exec + +import ( + "os/exec" + "path/filepath" + "runtime" +) + +func osProgram(program string) string { + if runtime.GOOS == "windows" { + program += ".exe" + } + return program +} + +func programPath(rootPath, program string) string { + return filepath.Join(rootPath, osProgram(program)) +} + +// IsBinaryPresent returns true if corresponding program exists on PATH +func IsBinaryPresent(program string) bool { + _, err := exec.LookPath(program) + return err == nil +} diff --git a/api/exec/utils_test.go b/api/exec/utils_test.go new file mode 100644 index 000000000..38695488a --- /dev/null +++ b/api/exec/utils_test.go @@ -0,0 +1,16 @@ +package exec + +import ( + "testing" +) + +func Test_isBinaryPresent(t *testing.T) { + + if !IsBinaryPresent("docker") { + t.Error("expect docker binary to exist on the path") + } + + if IsBinaryPresent("executable-with-this-name-should-not-exist") { + t.Error("expect binary with a random name to be missing on the path") + } +} diff --git a/api/go.mod b/api/go.mod index fcee3b6a8..6a862d58a 100644 --- a/api/go.mod +++ b/api/go.mod @@ -28,6 +28,7 @@ require ( 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 + github.com/stretchr/testify v1.6.1 // indirect golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1 golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933 // indirect golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 diff --git a/api/go.sum b/api/go.sum index d7b7db557..6cdfdbb85 100644 --- a/api/go.sum +++ b/api/go.sum @@ -262,12 +262,15 @@ github.com/src-d/gcfg v1.4.0 h1:xXbNR5AlLSA315x2UO+fTSSAXCDf+Ar38/6oyGbDKQ4= github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= github.com/urfave/cli v1.21.0/go.mod h1:lxDj6qX9Q6lWQxIrbrT0nwecwUtRnhVZAJjJZrVUZZQ= @@ -392,6 +395,8 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/api/http/handler/endpoints/endpoint_inspect.go b/api/http/handler/endpoints/endpoint_inspect.go index 1411e93cb..a4248c87f 100644 --- a/api/http/handler/endpoints/endpoint_inspect.go +++ b/api/http/handler/endpoints/endpoint_inspect.go @@ -6,7 +6,7 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" ) @@ -30,6 +30,7 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request) } hideFields(endpoint) + endpoint.ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion() return response.JSON(w, endpoint) } diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index fe7c489ae..fbd3a595e 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -5,12 +5,11 @@ import ( "strconv" "strings" - "github.com/portainer/portainer/api" - "github.com/portainer/libhttp/request" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" ) @@ -89,6 +88,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht for idx := range paginatedEndpoints { hideFields(&paginatedEndpoints[idx]) + paginatedEndpoints[idx].ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion() } w.Header().Set("X-Total-Count", strconv.Itoa(filteredEndpointCount)) diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index c004b5751..3dc8689d6 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -27,6 +27,7 @@ type Handler struct { ProxyManager *proxy.Manager ReverseTunnelService portainer.ReverseTunnelService SnapshotService portainer.SnapshotService + ComposeStackManager portainer.ComposeStackManager } // NewHandler creates a handler to manage endpoint operations. diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go index e77dfb765..b4a166d73 100644 --- a/api/http/handler/registries/registry_update.go +++ b/api/http/handler/registries/registry_update.go @@ -7,7 +7,7 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" ) @@ -71,7 +71,7 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * registry.Username = *payload.Username } - if payload.Password != nil { + if payload.Password != nil && *payload.Password != "" { registry.Password = *payload.Password } diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 6ceab991c..52fc2844c 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -7,11 +7,12 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/http/security" ) @@ -60,13 +61,14 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Name: payload.Name, - Type: portainer.DockerComposeStack, - EndpointID: endpoint.ID, - EntryPoint: filesystem.ComposeFileDefaultName, - Env: payload.Env, - Status: portainer.StackStatusActive, + ID: portainer.StackID(stackID), + Name: payload.Name, + Type: portainer.DockerComposeStack, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ComposeFileDefaultName, + Env: payload.Env, + Status: portainer.StackStatusActive, + CreationDate: time.Now().Unix(), } stackFolder := strconv.Itoa(int(stack.ID)) @@ -89,6 +91,8 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } + stack.CreatedBy = config.user.Username + err = handler.DataStore.Stack().CreateStack(stack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} @@ -146,13 +150,14 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Name: payload.Name, - Type: portainer.DockerComposeStack, - EndpointID: endpoint.ID, - EntryPoint: payload.ComposeFilePathInRepository, - Env: payload.Env, - Status: portainer.StackStatusActive, + ID: portainer.StackID(stackID), + Name: payload.Name, + Type: portainer.DockerComposeStack, + EndpointID: endpoint.ID, + EntryPoint: payload.ComposeFilePathInRepository, + Env: payload.Env, + Status: portainer.StackStatusActive, + CreationDate: time.Now().Unix(), } projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) @@ -185,6 +190,8 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } + stack.CreatedBy = config.user.Username + err = handler.DataStore.Stack().CreateStack(stack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} @@ -242,13 +249,14 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Name: payload.Name, - Type: portainer.DockerComposeStack, - EndpointID: endpoint.ID, - EntryPoint: filesystem.ComposeFileDefaultName, - Env: payload.Env, - Status: portainer.StackStatusActive, + ID: portainer.StackID(stackID), + Name: payload.Name, + Type: portainer.DockerComposeStack, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ComposeFileDefaultName, + Env: payload.Env, + Status: portainer.StackStatusActive, + CreationDate: time.Now().Unix(), } stackFolder := strconv.Itoa(int(stack.ID)) @@ -271,6 +279,8 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } + stack.CreatedBy = config.user.Username + err = handler.DataStore.Stack().CreateStack(stack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} @@ -347,7 +357,6 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig) !isAdminOrEndpointAdmin { composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint) - stackContent, err := handler.FileService.GetFileContent(composeFilePath) if err != nil { return err diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 0113e8a41..f7afbdeb5 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -6,11 +6,12 @@ import ( "path" "strconv" "strings" + "time" "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/http/security" ) @@ -55,14 +56,15 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Name: payload.Name, - Type: portainer.DockerSwarmStack, - SwarmID: payload.SwarmID, - EndpointID: endpoint.ID, - EntryPoint: filesystem.ComposeFileDefaultName, - Env: payload.Env, - Status: portainer.StackStatusActive, + ID: portainer.StackID(stackID), + Name: payload.Name, + Type: portainer.DockerSwarmStack, + SwarmID: payload.SwarmID, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ComposeFileDefaultName, + Env: payload.Env, + Status: portainer.StackStatusActive, + CreationDate: time.Now().Unix(), } stackFolder := strconv.Itoa(int(stack.ID)) @@ -85,6 +87,8 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } + stack.CreatedBy = config.user.Username + err = handler.DataStore.Stack().CreateStack(stack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} @@ -145,14 +149,15 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Name: payload.Name, - Type: portainer.DockerSwarmStack, - SwarmID: payload.SwarmID, - EndpointID: endpoint.ID, - EntryPoint: payload.ComposeFilePathInRepository, - Env: payload.Env, - Status: portainer.StackStatusActive, + ID: portainer.StackID(stackID), + Name: payload.Name, + Type: portainer.DockerSwarmStack, + SwarmID: payload.SwarmID, + EndpointID: endpoint.ID, + EntryPoint: payload.ComposeFilePathInRepository, + Env: payload.Env, + Status: portainer.StackStatusActive, + CreationDate: time.Now().Unix(), } projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) @@ -185,6 +190,8 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } + stack.CreatedBy = config.user.Username + err = handler.DataStore.Stack().CreateStack(stack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} @@ -249,14 +256,15 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Name: payload.Name, - Type: portainer.DockerSwarmStack, - SwarmID: payload.SwarmID, - EndpointID: endpoint.ID, - EntryPoint: filesystem.ComposeFileDefaultName, - Env: payload.Env, - Status: portainer.StackStatusActive, + ID: portainer.StackID(stackID), + Name: payload.Name, + Type: portainer.DockerSwarmStack, + SwarmID: payload.SwarmID, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ComposeFileDefaultName, + Env: payload.Env, + Status: portainer.StackStatusActive, + CreationDate: time.Now().Unix(), } stackFolder := strconv.Itoa(int(stack.ID)) @@ -279,6 +287,8 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } + stack.CreatedBy = config.user.Username + err = handler.DataStore.Stack().CreateStack(stack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index c706afbfd..caa537e2f 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -7,7 +7,7 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" ) @@ -78,6 +78,17 @@ func (handler *Handler) userCanAccessStack(securityContext *security.RestrictedR return handler.userIsAdminOrEndpointAdmin(user, endpointID) } +func (handler *Handler) userIsAdmin(userID portainer.UserID) (bool, error) { + user, err := handler.DataStore.User().User(userID) + if err != nil { + return false, err + } + + isAdmin := user.Role == portainer.AdministratorRole + + return isAdmin, nil +} + func (handler *Handler) userIsAdminOrEndpointAdmin(user *portainer.User, endpointID portainer.EndpointID) (bool, error) { isAdmin := user.Role == portainer.AdministratorRole diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index c9f115ad1..a9fdf2f36 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -183,9 +183,20 @@ func (handler *Handler) isValidStackFile(stackFileContent []byte, settings *port } func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *portainer.Stack, userID portainer.UserID) *httperror.HandlerError { - resourceControl := authorization.NewPrivateResourceControl(stack.Name, portainer.StackResourceControl, userID) + var resourceControl *portainer.ResourceControl - err := handler.DataStore.ResourceControl().CreateResourceControl(resourceControl) + isAdmin, err := handler.userIsAdmin(userID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err} + } + + if isAdmin { + resourceControl = authorization.NewAdministratorsOnlyResourceControl(stack.Name, portainer.StackResourceControl) + } else { + resourceControl = authorization.NewPrivateResourceControl(stack.Name, portainer.StackResourceControl, userID) + } + + err = handler.DataStore.ResourceControl().CreateResourceControl(resourceControl) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist resource control inside the database", err} } diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go index 6866c6809..eea3bd367 100644 --- a/api/http/handler/stacks/stack_delete.go +++ b/api/http/handler/stacks/stack_delete.go @@ -155,5 +155,6 @@ func (handler *Handler) deleteStack(stack *portainer.Stack, endpoint *portainer. if stack.Type == portainer.DockerSwarmStack { return handler.SwarmStackManager.Remove(stack, endpoint) } + return handler.ComposeStackManager.Down(stack, endpoint) } diff --git a/api/http/handler/stacks/stack_start.go b/api/http/handler/stacks/stack_start.go index 298a11d42..4c129eed1 100644 --- a/api/http/handler/stacks/stack_start.go +++ b/api/http/handler/stacks/stack_start.go @@ -4,13 +4,13 @@ import ( "errors" "net/http" + portainer "github.com/portainer/portainer/api" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" ) diff --git a/api/http/handler/stacks/stack_stop.go b/api/http/handler/stacks/stack_stop.go index ee2c13f32..48175e6b9 100644 --- a/api/http/handler/stacks/stack_stop.go +++ b/api/http/handler/stacks/stack_stop.go @@ -4,15 +4,14 @@ import ( "errors" "net/http" - httperrors "github.com/portainer/portainer/api/http/errors" - - "github.com/portainer/portainer/api/http/security" - httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" ) // POST request on /api/stacks/:id/stop diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index df4178f8f..adc1ca792 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -4,12 +4,13 @@ import ( "errors" "net/http" "strconv" + "time" "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" @@ -135,6 +136,9 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta return configErr } + stack.UpdateDate = time.Now().Unix() + stack.UpdatedBy = config.user.Username + err = handler.deployComposeStack(config) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} @@ -163,6 +167,9 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack return configErr } + stack.UpdateDate = time.Now().Unix() + stack.UpdatedBy = config.user.Username + err = handler.deploySwarmStack(config) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} diff --git a/api/http/proxy/factory/docker_compose.go b/api/http/proxy/factory/docker_compose.go new file mode 100644 index 000000000..7da8d898f --- /dev/null +++ b/api/http/proxy/factory/docker_compose.go @@ -0,0 +1,88 @@ +package factory + +import ( + "fmt" + "log" + "net" + "net/http" + "net/url" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/http/proxy/factory/dockercompose" +) + +// ProxyServer provide an extedned proxy with a local server to forward requests +type ProxyServer struct { + server *http.Server + Port int +} + +func (factory *ProxyFactory) NewDockerComposeAgentProxy(endpoint *portainer.Endpoint) (*ProxyServer, error) { + + if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { + return &ProxyServer{ + Port: factory.reverseTunnelService.GetTunnelDetails(endpoint.ID).Port, + }, nil + } + + endpointURL, err := url.Parse(endpoint.URL) + if err != nil { + return nil, err + } + + endpointURL.Scheme = "http" + httpTransport := &http.Transport{} + + if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify { + config, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify) + if err != nil { + return nil, err + } + + httpTransport.TLSClientConfig = config + endpointURL.Scheme = "https" + } + + proxy := newSingleHostReverseProxyWithHostHeader(endpointURL) + + proxy.Transport = dockercompose.NewAgentTransport(factory.signatureService, httpTransport) + + proxyServer := &ProxyServer{ + &http.Server{ + Handler: proxy, + }, + 0, + } + + return proxyServer, proxyServer.start() +} + +func (proxy *ProxyServer) start() error { + listener, err := net.Listen("tcp", ":0") + if err != nil { + return err + } + + proxy.Port = listener.Addr().(*net.TCPAddr).Port + go func() { + proxyHost := fmt.Sprintf("127.0.0.1:%d", proxy.Port) + log.Printf("Starting Proxy server on %s...\n", proxyHost) + + err := proxy.server.Serve(listener) + log.Printf("Exiting Proxy server %s\n", proxyHost) + + if err != http.ErrServerClosed { + log.Printf("Proxy server %s exited with an error: %s\n", proxyHost, err) + } + }() + + return nil +} + +// Close shuts down the server +func (proxy *ProxyServer) Close() { + if proxy.server != nil { + proxy.server.Close() + } +} diff --git a/api/http/proxy/factory/dockercompose/transport.go b/api/http/proxy/factory/dockercompose/transport.go new file mode 100644 index 000000000..b9be10e01 --- /dev/null +++ b/api/http/proxy/factory/dockercompose/transport.go @@ -0,0 +1,40 @@ +package dockercompose + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" +) + +type ( + // AgentTransport is an http.Transport wrapper that adds custom http headers to communicate to an Agent + AgentTransport struct { + httpTransport *http.Transport + signatureService portainer.DigitalSignatureService + endpointIdentifier portainer.EndpointID + } +) + +// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent +func NewAgentTransport(signatureService portainer.DigitalSignatureService, httpTransport *http.Transport) *AgentTransport { + transport := &AgentTransport{ + httpTransport: httpTransport, + signatureService: signatureService, + } + + return transport +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *AgentTransport) RoundTrip(request *http.Request) (*http.Response, error) { + + signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) + if err != nil { + return nil, err + } + + request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey()) + request.Header.Set(portainer.PortainerAgentSignatureHeader, signature) + + return transport.httpTransport.RoundTrip(request) +} diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go index 4fbacf590..c1d0de13b 100644 --- a/api/http/proxy/factory/kubernetes/transport.go +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -3,6 +3,7 @@ package kubernetes import ( "crypto/tls" "fmt" + "log" "net/http" "github.com/portainer/portainer/api/http/security" @@ -13,14 +14,16 @@ import ( type ( localTransport struct { - httpTransport *http.Transport - tokenManager *tokenManager + httpTransport *http.Transport + tokenManager *tokenManager + endpointIdentifier portainer.EndpointID } agentTransport struct { - httpTransport *http.Transport - tokenManager *tokenManager - signatureService portainer.DigitalSignatureService + httpTransport *http.Transport + tokenManager *tokenManager + signatureService portainer.DigitalSignatureService + endpointIdentifier portainer.EndpointID } edgeTransport struct { @@ -50,21 +53,11 @@ func NewLocalTransport(tokenManager *tokenManager) (*localTransport, error) { // RoundTrip is the implementation of the the http.RoundTripper interface func (transport *localTransport) RoundTrip(request *http.Request) (*http.Response, error) { - tokenData, err := security.RetrieveTokenData(request) + token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier) if err != nil { return nil, err } - var token string - if tokenData.Role == portainer.AdministratorRole { - token = transport.tokenManager.getAdminServiceAccountToken() - } else { - token, err = transport.tokenManager.getUserServiceAccountToken(int(tokenData.ID)) - if err != nil { - return nil, err - } - } - request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) return transport.httpTransport.RoundTrip(request) @@ -85,21 +78,11 @@ func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsCo // RoundTrip is the implementation of the the http.RoundTripper interface func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Response, error) { - tokenData, err := security.RetrieveTokenData(request) + token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier) if err != nil { return nil, err } - var token string - if tokenData.Role == portainer.AdministratorRole { - token = transport.tokenManager.getAdminServiceAccountToken() - } else { - token, err = transport.tokenManager.getUserServiceAccountToken(int(tokenData.ID)) - if err != nil { - return nil, err - } - } - request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) @@ -127,21 +110,11 @@ func NewEdgeTransport(reverseTunnelService portainer.ReverseTunnelService, endpo // RoundTrip is the implementation of the the http.RoundTripper interface func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response, error) { - tokenData, err := security.RetrieveTokenData(request) + token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier) if err != nil { return nil, err } - var token string - if tokenData.Role == portainer.AdministratorRole { - token = transport.tokenManager.getAdminServiceAccountToken() - } else { - token, err = transport.tokenManager.getUserServiceAccountToken(int(tokenData.ID)) - if err != nil { - return nil, err - } - } - request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) response, err := transport.httpTransport.RoundTrip(request) @@ -154,3 +127,27 @@ func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response return response, err } + +func getRoundTripToken( + request *http.Request, + tokenManager *tokenManager, + endpointIdentifier portainer.EndpointID, +) (string, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return "", err + } + + var token string + if tokenData.Role == portainer.AdministratorRole { + token = tokenManager.getAdminServiceAccountToken() + } else { + token, err = tokenManager.getUserServiceAccountToken(int(tokenData.ID)) + if err != nil { + log.Printf("Failed retrieving service account token: %v", err) + return "", err + } + } + + return token, nil +} diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index e539d89c2..2013647d8 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -1,6 +1,7 @@ package proxy import ( + "fmt" "net/http" "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" @@ -21,6 +22,7 @@ type ( proxyFactory *factory.ProxyFactory endpointProxies cmap.ConcurrentMap legacyExtensionProxies cmap.ConcurrentMap + k8sClientFactory *cli.ClientFactory } ) @@ -29,6 +31,7 @@ func NewManager(dataStore portainer.DataStore, signatureService portainer.Digita return &Manager{ endpointProxies: cmap.New(), legacyExtensionProxies: cmap.New(), + k8sClientFactory: kubernetesClientFactory, proxyFactory: factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager), } } @@ -41,13 +44,19 @@ func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpo return nil, err } - manager.endpointProxies.Set(string(endpoint.ID), proxy) + manager.endpointProxies.Set(fmt.Sprint(endpoint.ID), proxy) return proxy, nil } +// CreateComposeProxyServer creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies. +// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. +func (manager *Manager) CreateComposeProxyServer(endpoint *portainer.Endpoint) (*factory.ProxyServer, error) { + return manager.proxyFactory.NewDockerComposeAgentProxy(endpoint) +} + // GetEndpointProxy returns the proxy associated to a key func (manager *Manager) GetEndpointProxy(endpoint *portainer.Endpoint) http.Handler { - proxy, ok := manager.endpointProxies.Get(string(endpoint.ID)) + proxy, ok := manager.endpointProxies.Get(fmt.Sprint(endpoint.ID)) if !ok { return nil } @@ -56,8 +65,11 @@ func (manager *Manager) GetEndpointProxy(endpoint *portainer.Endpoint) http.Hand } // DeleteEndpointProxy deletes the proxy associated to a key +// and cleans the k8s endpoint client cache. DeleteEndpointProxy +// is currently only called for edge connection clean up. func (manager *Manager) DeleteEndpointProxy(endpoint *portainer.Endpoint) { - manager.endpointProxies.Remove(string(endpoint.ID)) + manager.endpointProxies.Remove(fmt.Sprint(endpoint.ID)) + manager.k8sClientFactory.RemoveKubeClient(endpoint) } // CreateLegacyExtensionProxy creates a new HTTP reverse proxy for a legacy extension and adds it to the registered proxies diff --git a/api/http/server.go b/api/http/server.go index 8f83529f1..35571d736 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -39,39 +39,41 @@ import ( "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" ) // Server implements the portainer.Server interface type Server struct { - BindAddress string - AssetsPath string - Status *portainer.Status - ReverseTunnelService portainer.ReverseTunnelService - ComposeStackManager portainer.ComposeStackManager - CryptoService portainer.CryptoService - SignatureService portainer.DigitalSignatureService - SnapshotService portainer.SnapshotService - FileService portainer.FileService - DataStore portainer.DataStore - GitService portainer.GitService - JWTService portainer.JWTService - LDAPService portainer.LDAPService - OAuthService portainer.OAuthService - SwarmStackManager portainer.SwarmStackManager - Handler *handler.Handler - SSL bool - SSLCert string - SSLKey string - DockerClientFactory *docker.ClientFactory - KubernetesClientFactory *cli.ClientFactory - KubernetesDeployer portainer.KubernetesDeployer + BindAddress string + AssetsPath string + Status *portainer.Status + ReverseTunnelService portainer.ReverseTunnelService + ComposeStackManager portainer.ComposeStackManager + CryptoService portainer.CryptoService + SignatureService portainer.DigitalSignatureService + SnapshotService portainer.SnapshotService + FileService portainer.FileService + DataStore portainer.DataStore + GitService portainer.GitService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + OAuthService portainer.OAuthService + SwarmStackManager portainer.SwarmStackManager + ProxyManager *proxy.Manager + KubernetesTokenCacheManager *kubernetes.TokenCacheManager + Handler *handler.Handler + SSL bool + SSLCert string + SSLKey string + DockerClientFactory *docker.ClientFactory + KubernetesClientFactory *cli.ClientFactory + KubernetesDeployer portainer.KubernetesDeployer } // Start starts the HTTP server func (server *Server) Start() error { - kubernetesTokenCacheManager := kubernetes.NewTokenCacheManager() - proxyManager := proxy.NewManager(server.DataStore, server.SignatureService, server.ReverseTunnelService, server.DockerClientFactory, server.KubernetesClientFactory, kubernetesTokenCacheManager) + kubernetesTokenCacheManager := server.KubernetesTokenCacheManager requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService) @@ -82,7 +84,7 @@ func (server *Server) Start() error { authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService authHandler.LDAPService = server.LDAPService - authHandler.ProxyManager = proxyManager + authHandler.ProxyManager = server.ProxyManager authHandler.KubernetesTokenCacheManager = kubernetesTokenCacheManager authHandler.OAuthService = server.OAuthService @@ -116,10 +118,10 @@ func (server *Server) Start() error { var endpointHandler = endpoints.NewHandler(requestBouncer) endpointHandler.DataStore = server.DataStore endpointHandler.FileService = server.FileService - endpointHandler.ProxyManager = proxyManager + endpointHandler.ProxyManager = server.ProxyManager endpointHandler.SnapshotService = server.SnapshotService - endpointHandler.ProxyManager = proxyManager endpointHandler.ReverseTunnelService = server.ReverseTunnelService + endpointHandler.ComposeStackManager = server.ComposeStackManager var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer) endpointEdgeHandler.DataStore = server.DataStore @@ -131,7 +133,7 @@ func (server *Server) Start() error { var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer) endpointProxyHandler.DataStore = server.DataStore - endpointProxyHandler.ProxyManager = proxyManager + endpointProxyHandler.ProxyManager = server.ProxyManager endpointProxyHandler.ReverseTunnelService = server.ReverseTunnelService var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) @@ -141,7 +143,7 @@ func (server *Server) Start() error { var registryHandler = registries.NewHandler(requestBouncer) registryHandler.DataStore = server.DataStore registryHandler.FileService = server.FileService - registryHandler.ProxyManager = proxyManager + registryHandler.ProxyManager = server.ProxyManager var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer) resourceControlHandler.DataStore = server.DataStore diff --git a/api/internal/authorization/access_control.go b/api/internal/authorization/access_control.go index 0e2d91ab7..eab0a3b16 100644 --- a/api/internal/authorization/access_control.go +++ b/api/internal/authorization/access_control.go @@ -6,6 +6,21 @@ import ( "github.com/portainer/portainer/api" ) +// NewAdministratorsOnlyResourceControl will create a new administrators only resource control associated to the resource specified by the +// identifier and type parameters. +func NewAdministratorsOnlyResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType) *portainer.ResourceControl { + return &portainer.ResourceControl{ + Type: resourceType, + ResourceID: resourceIdentifier, + SubResourceIDs: []string{}, + UserAccesses: []portainer.UserResourceAccess{}, + TeamAccesses: []portainer.TeamResourceAccess{}, + AdministratorsOnly: true, + Public: false, + System: false, + } +} + // NewPrivateResourceControl will create a new private resource control associated to the resource specified by the // identifier and type parameters. It automatically assigns it to the user specified by the userID parameter. func NewPrivateResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType, userID portainer.UserID) *portainer.ResourceControl { diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go index 707bf3924..a268150c9 100644 --- a/api/kubernetes/cli/client.go +++ b/api/kubernetes/cli/client.go @@ -40,6 +40,11 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService, revers } } +// Remove the cached kube client so a new one can be created +func (factory *ClientFactory) RemoveKubeClient(endpoint *portainer.Endpoint) { + factory.endpointClients.Remove(strconv.Itoa(int(endpoint.ID))) +} + // GetKubeClient checks if an existing client is already registered for the endpoint and returns it if one is found. // If no client is registered, it will create a new client, register it, and returns it. func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (portainer.KubeClient, error) { diff --git a/api/libcompose/compose_stack.go b/api/libcompose/compose_stack.go index ec885b65b..4ac6ebdb9 100644 --- a/api/libcompose/compose_stack.go +++ b/api/libcompose/compose_stack.go @@ -13,11 +13,12 @@ import ( "github.com/portainer/libcompose/lookup" "github.com/portainer/libcompose/project" "github.com/portainer/libcompose/project/options" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" ) const ( - dockerClientVersion = "1.24" + dockerClientVersion = "1.24" + composeSyntaxMaxVersion = "2" ) // ComposeStackManager represents a service for managing compose stacks. @@ -58,6 +59,11 @@ func (manager *ComposeStackManager) createClient(endpoint *portainer.Endpoint) ( return client.NewDefaultFactory(clientOpts) } +// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax +func (manager *ComposeStackManager) ComposeSyntaxMaxVersion() string { + return composeSyntaxMaxVersion +} + // Up will deploy a compose stack (equivalent of docker-compose up) func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error { diff --git a/api/portainer.go b/api/portainer.go index 64197edef..00e1c32de 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -190,24 +190,25 @@ type ( // Endpoint represents a Docker endpoint with all the info required // to connect to it Endpoint struct { - ID EndpointID `json:"Id"` - Name string `json:"Name"` - Type EndpointType `json:"Type"` - URL string `json:"URL"` - GroupID EndpointGroupID `json:"GroupId"` - PublicURL string `json:"PublicURL"` - TLSConfig TLSConfiguration `json:"TLSConfig"` - Extensions []EndpointExtension `json:"Extensions"` - AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"` - TagIDs []TagID `json:"TagIds"` - Status EndpointStatus `json:"Status"` - Snapshots []DockerSnapshot `json:"Snapshots"` - UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` - TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` - EdgeID string `json:"EdgeID,omitempty"` - EdgeKey string `json:"EdgeKey"` - EdgeCheckinInterval int `json:"EdgeCheckinInterval"` - Kubernetes KubernetesData `json:"Kubernetes"` + ID EndpointID `json:"Id"` + Name string `json:"Name"` + Type EndpointType `json:"Type"` + URL string `json:"URL"` + GroupID EndpointGroupID `json:"GroupId"` + PublicURL string `json:"PublicURL"` + TLSConfig TLSConfiguration `json:"TLSConfig"` + Extensions []EndpointExtension `json:"Extensions"` + AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"` + TagIDs []TagID `json:"TagIds"` + Status EndpointStatus `json:"Status"` + Snapshots []DockerSnapshot `json:"Snapshots"` + UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` + TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` + EdgeID string `json:"EdgeID,omitempty"` + EdgeKey string `json:"EdgeKey"` + EdgeCheckinInterval int `json:"EdgeCheckinInterval"` + Kubernetes KubernetesData `json:"Kubernetes"` + ComposeSyntaxMaxVersion string `json:"ComposeSyntaxMaxVersion"` // Deprecated fields // Deprecated in DBVersion == 4 @@ -554,6 +555,10 @@ type ( Env []Pair `json:"Env"` ResourceControl *ResourceControl `json:"ResourceControl"` Status StackStatus `json:"Status"` + CreationDate int64 + CreatedBy string + UpdateDate int64 + UpdatedBy string ProjectPath string } @@ -774,6 +779,7 @@ type ( // ComposeStackManager represents a service to manage Compose stacks ComposeStackManager interface { + ComposeSyntaxMaxVersion() string Up(stack *Stack, endpoint *Endpoint) error Down(stack *Stack, endpoint *Endpoint) error } @@ -1119,9 +1125,11 @@ type ( const ( // APIVersion is the version number of the Portainer API - APIVersion = "2.0.1" + APIVersion = "2.1.0" // DBVersion is the version number of the Portainer database DBVersion = 25 + // ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax + ComposeSyntaxMaxVersion = "3.9" // AssetsServerURL represents the URL of the Portainer asset server AssetsServerURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com" // MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved diff --git a/app/assets/css/app.css b/app/assets/css/app.css index 4f9ca5881..f813dbb2c 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -927,6 +927,27 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { z-index: 2; } +.striketext:before, +.striketext:after { + background-color: #777777; + content: ''; + display: inline-block; + height: 1px; + position: relative; + vertical-align: middle; + width: 50%; +} + +.striketext:before { + right: 0.5em; + margin-left: -50%; +} + +.striketext:after { + left: 0.5em; + margin-right: -50%; +} + /*bootbox override*/ .modal-open { padding-right: 0 !important; diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index 9ba136c4c..36835997d 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -4,100 +4,8 @@
{{ $ctrl.titleText }}
- - Columns - - + + Settings